Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
ad155d3
feature : OPIc 세션관리 + 답변 처리 파이프라인 구현 (#413)
hye-inA Jan 20, 2026
b30ea0a
feat: GAME_START, ROUND_END 메시지에 serverTime 추가
DDINGJOO Jan 20, 2026
90d1272
Merge pull request #434 from Language-Study-Prooject/feature/418/419/…
DDINGJOO Jan 20, 2026
6474f76
feat: WebSocketMessageHelper 유틸리티 클래스 추가
DDINGJOO Jan 20, 2026
1107a59
feat: 모든 WebSocket 메시지에 domain 필드 추가
DDINGJOO Jan 20, 2026
f1236b0
Merge pull request #435 from Language-Study-Prooject/feature/418/420/…
DDINGJOO Jan 20, 2026
9c61115
feat: GameSession 모델 클래스 생성
DDINGJOO Jan 20, 2026
bf25a6e
feat: GameSessionRepository 구현
DDINGJOO Jan 20, 2026
13f254e
refactor: ChatRoom에서 게임 필드 분리
DDINGJOO Jan 20, 2026
5195ca6
refactor: GameSession 기반으로 전체 게임 로직 리팩토링
DDINGJOO Jan 20, 2026
f75cf4d
Merge pull request #436 from Language-Study-Prooject/feature/418/421/…
DDINGJOO Jan 20, 2026
2fc718c
feat: GameSessionHandler Lambda 및 게임 세션 API 구현
DDINGJOO Jan 20, 2026
421cb60
Merge pull request #437 from Language-Study-Prooject/feature/418/422/…
DDINGJOO Jan 20, 2026
ed29c55
feat: 게임 시작 7분 후 자동 종료 기능 구현
DDINGJOO Jan 20, 2026
d0eb4b4
Merge pull request #438 from Language-Study-Prooject/feature/417/game…
DDINGJOO Jan 20, 2026
4f49838
fix : 메모리 증가 및 Lambda 응답 제한 시간 Cognito 트리거 제한시간과 동일하게 수정 (#439)
hye-inA Jan 20, 2026
05e8627
feat: 캐치마인드 게임 방 분리 기능 구현 (#455)
DDINGJOO Jan 20, 2026
0f29fb9
feat: 참가자 닉네임 및 방장 변경 WebSocket 알림 구현 (#456)
DDINGJOO Jan 20, 2026
469cefd
fix: ChatRoomFunction에 UserTable DynamoDB 권한 추가
DDINGJOO Jan 21, 2026
229be3a
fix: GameSettings에 @DynamoDbBean 어노테이션 추가
DDINGJOO Jan 21, 2026
0748cf5
fix: ChatRoomFunction에 WEBSOCKET_ENDPOINT 환경변수 및 권한 추가
DDINGJOO Jan 21, 2026
3f8c114
fix: WebSocket Lambda 함수들에 WEBSOCKET_ENDPOINT 환경변수 추가
DDINGJOO Jan 21, 2026
fbe58a3
fix: Grammar WebSocket Lambda 환경 변수 및 권한 추가
DDINGJOO Jan 21, 2026
e953152
feat: GSI1SK 확장성 있는 재설계 및 DB 레벨 필터링
DDINGJOO Jan 21, 2026
c34d183
fix: increase stats query limit to 100 days
DDINGJOO Jan 21, 2026
7db99c8
feat: improve game round and connection management logic
DDINGJOO Jan 21, 2026
7cdb9b3
refactor: 코드 정리 및 미사용 클래스 제거 (#459)
DDINGJOO Jan 21, 2026
90c0135
refactor(all): DI 패턴 및 전략 패턴 적용 (#461)
DDINGJOO Jan 21, 2026
ab28efd
refactor: relocate and restructure seed data files
DDINGJOO Jan 21, 2026
3a95101
chore: seed 데이터 폴더 구조 정리
DDINGJOO Jan 21, 2026
e87cff6
feat: add CI/CD pipeline configuration for CodePipeline
DDINGJOO Jan 22, 2026
4e3785d
fix: add SNS topic policy and DependsOn for notification rule
DDINGJOO Jan 22, 2026
126f89a
fix: correct paths in buildspec.yml for CodeBuild
DDINGJOO Jan 22, 2026
8fa4eb6
fix: remove hardcoded JAVA_HOME, use runtime default
DDINGJOO Jan 22, 2026
00dfc65
fix: add gradle wrapper for CI/CD build
DDINGJOO Jan 22, 2026
3f5a880
fix: use single line sam package command with hardcoded bucket
DDINGJOO Jan 22, 2026
b26f47c
fix: use existing stack name group2-englishstudy-chatting
DDINGJOO Jan 22, 2026
033c98e
fix: add missing WEBSOCKET_ENDPOINT env var to WebSocket connect func…
DDINGJOO Jan 22, 2026
14a67da
merge: develop 브랜치 최신화
DDINGJOO Jan 22, 2026
9b3f286
docs: update FRONTEND-API-GUIDE with new RoomType/RoomStatus structure
DDINGJOO Jan 22, 2026
8296952
feat : WebSocket Connect, Disconnect 핸들러 & SpeakingConnecion 모델 구현
hye-inA Jan 22, 2026
fc6f9e6
feat : WebSocket 메시지 처리 handler, service 구현
hye-inA Jan 22, 2026
f39bdf9
feat : WebSocket 연결 정보 Repository 구현
hye-inA Jan 22, 2026
674f87c
fix: update WebSocketDisconnectHandler to use GameSession model
DDINGJOO Jan 22, 2026
540f92d
perf: optimize CI/CD build time
DDINGJOO Jan 22, 2026
96dcbc2
feat: add custom CodeBuild Docker image with pre-installed tools
DDINGJOO Jan 22, 2026
02dab52
feature : AI 영어 회화 연습 기능 (#468)
hye-inA Jan 22, 2026
c7c49d2
Merge remote-tracking branch 'origin/prod' into prod
DDINGJOO Jan 22, 2026
4ffd919
fix: remove typo in SpeakingConnectionRepository
DDINGJOO Jan 22, 2026
ccaa034
fix : 오타 수정
hye-inA Jan 22, 2026
e87606c
Merge branch 'prod' of https://github.com/Language-Study-Prooject/BE_…
hye-inA Jan 22, 2026
c201a07
chore: trigger build test with custom Docker image
DDINGJOO Jan 22, 2026
4bacba2
chore: remove unnecessary newlines and whitespace across WebSocket ha…
DDINGJOO Jan 22, 2026
dc0348a
chore: remove unnecessary newlines and whitespace across WebSocket ha…
DDINGJOO Jan 22, 2026
6810f41
refactor : websocket -> rest api 전환
hye-inA Jan 22, 2026
76045eb
feat : speaking handler REST로 교체
hye-inA Jan 22, 2026
2c7094a
feat : speaking 관련 dto 생성
hye-inA Jan 22, 2026
5ad8fd6
refacotor : 기존 service 코드 로직 재사용 및 repository 리펙토링
hye-inA Jan 22, 2026
c1ca2c0
fix: add CORS headers to API Gateway error responses (#479)
DDINGJOO Jan 22, 2026
af48d60
Merge branch 'prod' of https://github.com/Language-Study-Prooject/BE_…
hye-inA Jan 22, 2026
f4ab156
feat : prod branch merge
hye-inA Jan 22, 2026
6c4cc89
feat : speaking rest API 람다 함수 추가
hye-inA Jan 22, 2026
efcf597
Merge branch 'develop' into feature/speaking-ai-service
hye-inA Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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 신뢰도
) {}
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class);
private static final Gson gson = new GsonBuilder().create();

private static final Map<String, String> 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<String> 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<String, Object> body) {
return new APIGatewayProxyResponseEvent()
.withStatusCode(statusCode)
.withHeaders(CORS_HEADERS)
.withBody(gson.toJson(body));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<SpeakingSession> 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<SpeakingSession> 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);
}
}
Loading