Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -24,6 +24,7 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand Down Expand Up @@ -119,9 +120,10 @@ public ResponseEntity<Void> deleteProblem(
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<ProblemResponse>> createProblemsByAi(
@PathVariable UUID spaceId,
@RequestHeader("Idempotency-Key") UUID idempotencyKey,
@RequestBody @Valid ProblemAiCreateRequest request) {

List<ProblemResponse> response = problemService.createProblemsByAi(spaceId, request);
List<ProblemResponse> response = problemService.createProblemsByAi(spaceId, idempotencyKey, request);

return ResponseEntity
.status(HttpStatus.CREATED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand Down Expand Up @@ -89,10 +90,11 @@ public ResponseEntity<Void> deleteRoomProblem(
public ResponseEntity<List<RoomProblemResponse>> createRoomProblemsByAi(
@AuthenticationPrincipal MoMoGoUserDetails userDetails,
@PathVariable UUID roomId,
@RequestHeader("Idempotency-Key") UUID idempotencyKey,
@Valid @RequestBody RoomProblemAiCreateRequest request) {

List<RoomProblemResponse> response = roomProblemService.
createRoomProblemsByAi(userDetails.getUserResponse().id(), roomId, request);
createRoomProblemsByAi(userDetails.getUserResponse().id(), roomId, idempotencyKey, request);

return ResponseEntity
.status(HttpStatus.CREATED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ public enum ProblemErrorCode implements ErrorCode {
HttpStatus.SERVICE_UNAVAILABLE, "AI 문제 생성에 실패했습니다. 잠시 후 다시 시도해주세요."),

COUNTER_NOT_FOUND(3010, "COUNTER_NOT_FOUND",
HttpStatus.INTERNAL_SERVER_ERROR, "문제 통계 정보를 찾을 수 없습니다.");
HttpStatus.INTERNAL_SERVER_ERROR, "문제 통계 정보를 찾을 수 없습니다."),

DUPLICATE_AI_REQUEST(3011, "DUPLICATE_AI_REQUEST",
HttpStatus.CONFLICT, "이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요.");

private final int numeric;
private final String errorKey;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,5 @@ ProblemCursorResponse getProblems(
* @param spaceId 공간 ID
* @param request AI 문제 자동 생성 요청 DTO
*/
List<ProblemResponse> createProblemsByAi(UUID spaceId, ProblemAiCreateRequest request);
List<ProblemResponse> createProblemsByAi(UUID spaceId, UUID idempotencyKey, ProblemAiCreateRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
import com.momogo.core.domain.user.entity.UserProblemId;
import com.momogo.core.domain.user.repository.UserProblemRepository;
import com.momogo.core.domain.user.repository.UserRepository;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -42,14 +45,16 @@ public class ProblemServiceImpl implements ProblemService {

private final ProblemRepository problemRepository;

private final RedisTemplate<String, Object> redisTemplate;

private final ProblemCategoryRepository categoryRepository;

private final ProblemCountersRepository countersRepository;

private final SpaceRepository spaceRepository;

private final ProblemMapper problemMapper;

private final ProblemGenerationService problemGenerationService;

private final ProblemPersister problemPersister;
Expand All @@ -58,6 +63,15 @@ public class ProblemServiceImpl implements ProblemService {

private final UserRepository userRepository;

// GET한 값이 내 토큰과 같을 때만 DEL - TTL 만료로 락이 다른 요청에 넘어간 경우 잘못 삭제하는 것 방지
private static final RedisScript<Long> UNLOCK_SCRIPT = RedisScript.of("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);

/**
* 문제 직접 생성
*
Expand Down Expand Up @@ -251,7 +265,8 @@ public ProblemSolveResponse solveProblem(

String normalizedUserAnswer = request.userAnswer().replaceAll("\\s+", "").toLowerCase();

String normalizedCorrectAnswer = problem.getCorrectAnswer().replaceAll("\\s+", "").toLowerCase();
String normalizedCorrectAnswer = problem.getCorrectAnswer().replaceAll("\\s+",
"").toLowerCase();

boolean isSolved = normalizedUserAnswer.contains(normalizedCorrectAnswer);

Expand Down Expand Up @@ -289,28 +304,47 @@ public ProblemSolveResponse solveProblem(

/**
* AI 기반 문제 자동 생성 (ADMIN 전용)
* @param spaceId 공간 ID
* @param request AI 문제 자동 생성 요청 DTO
*
* @param spaceId 공간 ID
* @param request AI 문제 자동 생성 요청 DTO
*/
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 해당 메서드 자체는 트랜잭션 없이 실행
public List<ProblemResponse> createProblemsByAi(UUID spaceId, ProblemAiCreateRequest request) {

// 공간 / 카테고리 검증
if (!spaceRepository.existsById(spaceId)) {
throw new BusinessException(ProblemErrorCode.SPACE_NOT_FOUND);
public List<ProblemResponse> createProblemsByAi(UUID spaceId, UUID idempotencyKey,
ProblemAiCreateRequest request) {

String lockKey = "idem:problem-ai:" + idempotencyKey;
// 락 값을 요청 식별 토큰으로 - TTL 만료 후 다른 요청이 같은 키로 락을 새로 잡았을 때 구분하기 위함
String ownerToken = UUID.randomUUID().toString();

Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, ownerToken, Duration.ofMinutes(10));

if (Boolean.FALSE.equals(acquired)) {
throw new BusinessException(ProblemErrorCode.DUPLICATE_AI_REQUEST);
}

if (!categoryRepository.existsById(request.categoryId())) {
throw new BusinessException(ProblemErrorCode.CATEGORY_NOT_FOUND);
try {

// 공간 / 카테고리 검증
if (!spaceRepository.existsById(spaceId)) {
throw new BusinessException(ProblemErrorCode.SPACE_NOT_FOUND);
}

if (!categoryRepository.existsById(request.categoryId())) {
throw new BusinessException(ProblemErrorCode.CATEGORY_NOT_FOUND);
}

// AI 생성 호출 (트랜잭션 밖 - 커넥션 안 잡고 LLM 응답 대기)
List<GeneratedProblemData> generated = problemGenerationService.generateProblems(
request.referenceText(),
request.questionCount()
);

return problemPersister.saveGeneratedProblems(spaceId, request.categoryId(), generated);
} finally {
// 성공/실패/Error 무관 항상 해제. 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음)
redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken);
}

// AI 생성 호출 (트랜잭션 밖 - 커넥션 안 잡고 LLM 응답 대기)
List<GeneratedProblemData> generated = problemGenerationService.generateProblems(
request.referenceText(),
request.questionCount()
);

return problemPersister.saveGeneratedProblems(spaceId, request.categoryId(), generated);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum RoomProblemErrorCode implements ErrorCode {

NOT_FOUND(7001, "NOT_FOUND", HttpStatus.NOT_FOUND, "존재하지 않는 방 문제입니다."),
ROOM_NOT_STARTED(7002, "ROOM_NOT_STARTED", HttpStatus.FORBIDDEN, "아직 시작되지 않은 시험입니다."),
ROOM_ACCESS_DENIED(7003, "ROOM_ACCESS_DENIED", HttpStatus.FORBIDDEN, "해당 방에 접근 권한이 없습니다.");
ROOM_ACCESS_DENIED(7003, "ROOM_ACCESS_DENIED", HttpStatus.FORBIDDEN, "해당 방에 접근 권한이 없습니다."),
DUPLICATE_AI_REQUEST(7004, "DUPLICATE_AI_REQUEST", HttpStatus.CONFLICT, "이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요.");

private final int numeric;
private final String errorKey;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ public interface RoomProblemService {
* @param roomId 방 ID
* @param request AI 문제 생성 DTO
*/
List<RoomProblemResponse> createRoomProblemsByAi(UUID userId, UUID roomId, RoomProblemAiCreateRequest request);
List<RoomProblemResponse> createRoomProblemsByAi(UUID userId, UUID roomId, UUID idempotencyKey, RoomProblemAiCreateRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@
import com.momogo.core.domain.user.entity.User;
import com.momogo.core.domain.user.entity.enums.UserRole;
import com.momogo.core.domain.user.repository.UserRepository;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -33,8 +36,19 @@
@Transactional(readOnly = true)
public class RoomProblemServiceImpl implements RoomProblemService {

// GET한 값이 내 토큰과 같을 때만 DEL - TTL 만료로 락이 다른 요청에 넘어간 경우 잘못 삭제하는 것 방지
private static final RedisScript<Long> UNLOCK_SCRIPT = RedisScript.of("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);

private final RoomRepository roomRepository;

private final RedisTemplate<String, Object> redisTemplate;

private final RoomProblemRepository roomProblemRepository;

private final UserRepository userRepository;
Expand Down Expand Up @@ -89,9 +103,11 @@ public RoomProblemResponse updateRoomProblem(

RoomProblem roomProblem = getRoomProblem(roomId, roomProblemId);

ProblemCategory category = request.categoryId() != null ? getCategory(request.categoryId()) : null;
ProblemCategory category =
request.categoryId() != null ? getCategory(request.categoryId()) : null;

roomProblem.update(category, null, request.name(), request.content(), request.explanation(), request.correctAnswer());
roomProblem.update(category, null, request.name(), request.content(), request.explanation(),
request.correctAnswer());

return roomProblemMapper.toResponse(roomProblem);
}
Expand Down Expand Up @@ -123,21 +139,40 @@ public void deleteRoomProblem(UUID userId, UUID roomId, UUID roomProblemId) {
public List<RoomProblemResponse> createRoomProblemsByAi(
UUID userId,
UUID roomId,
UUID idempotencyKey,
RoomProblemAiCreateRequest request) {

Room room = roomRepository.findByIdWithSpace(roomId)
.orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND));
// 같은 idempotencyKey로 들어온 재시도(더블클릭/네트워크 재전송)를 차단
// 인스턴스가 여러 대라도 Redis가 공용 상태를 갖고 있어 판단 가능
String lockKey = "idem:room-problem-ai:" + idempotencyKey;
// 락 값을 요청 식별 토큰으로 - TTL 만료 후 다른 요청이 같은 키로 락을 새로 잡았을 때 구분하기 위함
String ownerToken = UUID.randomUUID().toString();

validateAdmin(userId, room);
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, ownerToken, Duration.ofMinutes(10));

ProblemCategory category = getCategory(request.categoryId());
if (Boolean.FALSE.equals(acquired)) {
throw new BusinessException(RoomProblemErrorCode.DUPLICATE_AI_REQUEST);
}

try {
Room room = roomRepository.findByIdWithSpace(roomId)
.orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND));

validateAdmin(userId, room);
Comment thread
Junkov0 marked this conversation as resolved.

List<GeneratedProblemData> generated = problemGenerationService.generateProblems(
request.referenceText(),
request.questionCount()
);
ProblemCategory category = getCategory(request.categoryId());

return roomProblemPersister.saveGeneratedProblems(roomId, category, generated);
List<GeneratedProblemData> generated = problemGenerationService.generateProblems(
request.referenceText(),
request.questionCount()
);

return roomProblemPersister.saveGeneratedProblems(roomId, category, generated);
} finally {
// 성공/실패/Error 무관 항상 해제. 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음)
redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken);
}
}


Expand Down Expand Up @@ -170,7 +205,8 @@ private void validateAdmin(UUID userId, Room room) {
User user = userRepository.findByIdWithSpace(userId)
.orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_USER_NOT_FOUND));

if (user.getRole() != UserRole.ADMIN || user.getSpace() == null || !user.getSpace().getId().equals(room.getSpace().getId())) {
if (user.getRole() != UserRole.ADMIN || user.getSpace() == null
|| !user.getSpace().getId().equals(room.getSpace().getId())) {
throw new BusinessException(SpaceErrorCode.NOT_SPACE_ADMIN);
}
}
Expand Down
1 change: 1 addition & 0 deletions momogo-frontend/src/pages/SpacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export const SpacePage: React.FC<SpacePageProps> = ({ user, space, onBack, showT
setLoading(true);
await request(`/api/spaces/${space.id}/problems/ai`, {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({
categoryId: aiCategory,
referenceText: aiRefData,
Expand Down