diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java index 9ec2c763..403309cf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java @@ -1,13 +1,16 @@ package com.devkor.ifive.nadab.domain.admin.api; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatRagBackfillResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatRagBackfillStatusResponse; import com.devkor.ifive.nadab.domain.askchat.application.AskChatRagBackfillService; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillResultDto; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; import io.swagger.v3.oas.annotations.Hidden; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -20,6 +23,12 @@ public class AdminAskChatRagController { private final AskChatRagBackfillService askChatRagBackfillService; + @GetMapping("/backfill/daily-answers/status") + public ResponseEntity> getDailyAnswerBackfillStatus() { + AskChatRagBackfillStatusDto status = askChatRagBackfillService.getCompletedDailyAnswerStatus(); + return ApiResponseEntity.ok(AdminAskChatRagBackfillStatusResponse.from(status)); + } + @PostMapping("/backfill/daily-answers") public ResponseEntity> backfillDailyAnswers() { AskChatRagBackfillResultDto result = askChatRagBackfillService.backfillCompletedDailyAnswers(); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatRagBackfillStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatRagBackfillStatusResponse.java new file mode 100644 index 00000000..7072d075 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatRagBackfillStatusResponse.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; + +public record AdminAskChatRagBackfillStatusResponse( + long targetCount, + long indexedCount, + long failedCount +) { + + public static AdminAskChatRagBackfillStatusResponse from(AskChatRagBackfillStatusDto status) { + return new AdminAskChatRagBackfillStatusResponse( + status.targetCount(), + status.indexedCount(), + status.failedCount() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java index 06741d74..c9ae2f0a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.request.AskChatSessionStartRequest; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatHomeResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatTurnChargeResponse; import com.devkor.ifive.nadab.domain.askchat.application.AskChatMessageCommandService; import com.devkor.ifive.nadab.domain.askchat.application.AskChatSessionService; @@ -158,6 +159,37 @@ public ResponseEntity> chargeTurns( return ApiResponseEntity.ok(response); } + @GetMapping("/turns/remaining") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "물어보기 남은 메시지 횟수 조회", + description = """ + 현재 사용자가 사용할 수 있는 물어보기 남은 메시지 횟수만 조회합니다.
+ 홈 전체 정보를 다시 조회하지 않고 질문 전송/충전 이후 카운터만 갱신할 때 사용할 수 있습니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "남은 메시지 횟수 조회 성공", + content = @Content(schema = @Schema(implementation = AskChatRemainingMessageCountResponse.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: ASK_CHAT_WALLET_NOT_FOUND - Ask Chat 대화권 지갑을 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> getRemainingTurns( + @AuthenticationPrincipal UserPrincipal principal + ) { + AskChatRemainingMessageCountResponse response = + askChatSessionService.getRemainingMessageCount(principal.getId()); + return ApiResponseEntity.ok(response); + } + @PostMapping("/messages") @PreAuthorize("isAuthenticated()") @Operation( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java index 677e5a8c..c6ec7749 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatQuestionSendResponse.java @@ -18,6 +18,9 @@ public record AskChatQuestionSendResponse( @Schema(description = "답변 생성 성공/실패 상태. 실패 시 프론트에서는 이 값을 기준으로 모달/토스트를 표시합니다.") AskChatAnswerGenerationResponse answerGeneration, + @Schema(description = "답변 생성 처리 이후 사용 가능한 남은 메시지 횟수. 성공 시에는 차감 이후 값, 실패 시에는 환불 이후 값입니다.", example = "8") + int remainingMessageCount, + @Schema(description = "AI가 제안한 후속 추천 질문. 생성 실패 시 빈 배열") List followUpQuestions ) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java new file mode 100644 index 00000000..b6b70126 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatRemainingMessageCountResponse.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.askchat.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "물어보기 남은 메시지 횟수 응답") +public record AskChatRemainingMessageCountResponse( + @Schema(description = "사용 가능한 남은 메시지 횟수", example = "9") + int remainingMessageCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java index fbe6bbb2..6159db56 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandService.java @@ -17,6 +17,7 @@ import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatMessageRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagDocumentRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSessionRepository; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatAnswerLlmClient; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; @@ -48,6 +49,7 @@ public class AskChatMessageCommandService { private final AskChatAnswerLlmClient askChatAnswerLlmClient; private final AskChatAnswerProperties askChatAnswerProperties; private final AskChatTurnReservationService askChatTurnReservationService; + private final AskChatWalletRepository askChatWalletRepository; @Transactional public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, String content) { @@ -70,6 +72,7 @@ public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, Str AskChatMessage assistantMessage; AskChatAnswerGenerationResponse answerGeneration; + int remainingMessageCount; List followUpQuestions; try { AskChatAnswerGenerationResult generationResult = askChatAnswerLlmClient.generate(context); @@ -78,12 +81,14 @@ public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, Str saveMessageReferences(assistantMessage, generationResult); askChatTurnReservationService.confirm(turnReservation); session = completeAnsweredTurn(userId, session.getId()); + remainingMessageCount = getRemainingMessageCount(userId); answerGeneration = AskChatAnswerGenerationResponse.completed(); followUpQuestions = generationResult.answer().followUpQuestions(); } catch (AiServiceException e) { long generationDurationMs = elapsedMillis(generationStartedAt); saveFailedAssistantMessage(session, e, generationDurationMs); askChatTurnReservationService.refund(userId, session, turnReservation); + remainingMessageCount = getRemainingMessageCount(userId); assistantMessage = null; answerGeneration = AskChatAnswerGenerationResponse.failed( e.getErrorCode(), @@ -97,6 +102,7 @@ public AskChatQuestionSendResponse sendQuestion(Long userId, Long sessionId, Str AskChatMessageResponse.from(userMessage), assistantMessage == null ? null : AskChatMessageResponse.from(assistantMessage), answerGeneration, + remainingMessageCount, followUpQuestions ); } @@ -159,6 +165,12 @@ private AskChatSession getSession(Long userId, Long sessionId) { .orElseThrow(() -> new NotFoundException(ErrorCode.ASK_CHAT_SESSION_NOT_FOUND)); } + private int getRemainingMessageCount(Long userId) { + return askChatWalletRepository.findByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ASK_CHAT_WALLET_NOT_FOUND)) + .getTotalTurnBalance(); + } + private AskChatSession completeAnsweredTurn(Long userId, Long sessionId) { int updated = askChatSessionRepository.completeAnsweredTurn( sessionId, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java index 29000805..a2f258ce 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.askchat.application; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillResultDto; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagBackfillQueryRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatEmbeddingClient; @@ -19,6 +20,10 @@ public class AskChatRagBackfillService { private final AskChatRagIndexingService indexingService; private final AskChatEmbeddingClient embeddingClient; + public AskChatRagBackfillStatusDto getCompletedDailyAnswerStatus() { + return backfillQueryRepository.findCompletedDailyAnswerStatus(embeddingClient.version()); + } + public AskChatRagBackfillResultDto backfillCompletedDailyAnswers() { List targets = backfillQueryRepository.findCompletedDailyAnswerTargets( embeddingClient.version(), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java index a5d1ae5b..a55f3bd7 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java @@ -2,6 +2,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatHomeResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; @@ -70,6 +71,14 @@ public AskChatQuestionSendResponse startSession(Long userId, String content) { return askChatMessageCommandService.sendQuestion(userId, session.getId(), content); } + @Transactional(readOnly = true) + public AskChatRemainingMessageCountResponse getRemainingMessageCount(Long userId) { + AskChatWallet askChatWallet = askChatWalletRepository.findByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.ASK_CHAT_WALLET_NOT_FOUND)); + + return new AskChatRemainingMessageCountResponse(askChatWallet.getTotalTurnBalance()); + } + private void validateMinimumAnswerCount(Long userId) { if (answerEntryRepository.countByUserId(userId) < MIN_ANSWER_COUNT_TO_USE_ASK_CHAT) { throw new BadRequestException(ErrorCode.ASK_CHAT_NOT_ENOUGH_ANSWERS); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/dto/AskChatRagBackfillStatusDto.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/dto/AskChatRagBackfillStatusDto.java new file mode 100644 index 00000000..6b6a9f50 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/dto/AskChatRagBackfillStatusDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.askchat.core.dto; + +public record AskChatRagBackfillStatusDto( + long targetCount, + long indexedCount, + long failedCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/properties/AskChatAnswerProperties.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/properties/AskChatAnswerProperties.java index 7fdbe213..a44eb376 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/properties/AskChatAnswerProperties.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/properties/AskChatAnswerProperties.java @@ -23,11 +23,11 @@ public class AskChatAnswerProperties { private LlmProvider provider = LlmProvider.OPENAI; @NotBlank - private String model = "gpt-4o-mini"; + private String model = "gpt-5.6-luna"; @DecimalMin("0.0") @DecimalMax("2.0") - private double temperature = 0.3; + private double temperature = 1.0; @Min(1) private int maxTokens = 900; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java index 43b8115b..ae466c48 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.askchat.core.repository; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import lombok.RequiredArgsConstructor; @@ -43,6 +44,33 @@ AND NOT EXISTS ( .addValue("limit", limit), this::mapTarget); } + public AskChatRagBackfillStatusDto findCompletedDailyAnswerStatus(int embeddingVersion) { + String sql = """ + SELECT COUNT(*) FILTER (WHERE d.id IS NULL) AS target_count, + COUNT(*) FILTER (WHERE d.embedding_status = 'COMPLETED') AS indexed_count, + COUNT(*) FILTER ( + WHERE d.embedding_status IN ('FAILED', 'DEAD_LETTER') + ) AS failed_count + FROM daily_reports dr + JOIN answer_entries ae ON ae.id = dr.answer_entry_id + LEFT JOIN ask_chat_rag_documents d + ON d.source_type = 'ANSWER_ENTRY' + AND d.source_id = ae.id + AND d.embedding_version = :embeddingVersion + WHERE dr.status = 'COMPLETED' + """; + + return jdbcTemplate.queryForObject( + sql, + new MapSqlParameterSource("embeddingVersion", embeddingVersion), + (rs, rowNum) -> new AskChatRagBackfillStatusDto( + rs.getLong("target_count"), + rs.getLong("indexed_count"), + rs.getLong("failed_count") + ) + ); + } + private AskChatRagBackfillTargetDto mapTarget(ResultSet rs, int rowNum) throws SQLException { String interestCode = rs.getString("interest_code"); return new AskChatRagBackfillTargetDto( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClient.java index 137f2f27..59671441 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClient.java @@ -76,7 +76,7 @@ private OpenAiChatOptions options() { return OpenAiChatOptions.builder() .model(properties.getModel()) .temperature(properties.getTemperature()) - .maxTokens(properties.getMaxTokens()) + .maxCompletionTokens(properties.getMaxTokens()) .build(); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java index 659428a4..f74c3dd2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java @@ -28,7 +28,8 @@ public class LocalAuthController { @Operation( summary = "더미 유저 로그인", description = """ - 로컬 환경에서 더미 유저로 로그인하여 액세스 토큰을 발급받습니다. + 로컬 환경에서 더미 유저로 로그인하여 액세스 토큰을 발급받습니다.
+ 더미 유저가 없을 시에는 자동 생성합니다. """ ) public ResponseEntity> dummyLogin() { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java index 02ad09b2..2ca8ccca 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java @@ -1,19 +1,20 @@ package com.devkor.ifive.nadab.domain.auth.application; import com.devkor.ifive.nadab.domain.auth.api.dto.response.LocalTokenResponse; +import com.devkor.ifive.nadab.domain.auth.infra.LocalDummyUserRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; -import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; -import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.security.token.AccessTokenProvider; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +@Profile("local") @Service @Transactional @RequiredArgsConstructor @@ -21,24 +22,17 @@ public class LocalTokenService { private final AccessTokenProvider accessTokenProvider; private final UserRepository userRepository; - private final UserWalletRepository userWalletRepository; + private final LocalDummyUserRepository localDummyUserRepository; // 로컬 로그인용 토큰 발급 public LocalTokenResponse issueDummyAccessToken() { - User user = userRepository.findById(11111L) + Long dummyUserId = localDummyUserRepository.createIfAbsent(); + User user = userRepository.findById(dummyUserId) .orElseThrow(() -> new NotFoundException(ErrorCode.AUTH_DUMMY_USER_NOT_FOUND)); - UserWallet wallet = getOrCreateWallet(user); - List roles = List.of("USER"); String accessToken = accessTokenProvider.generateToken(user.getId(), roles); return new LocalTokenResponse(accessToken); } - - public UserWallet getOrCreateWallet(User user) { - return userWalletRepository.findByUserId(user.getId()) - .orElseGet(() -> userWalletRepository.save(UserWallet.create(user))); - } - } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepository.java new file mode 100644 index 00000000..92846c6c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepository.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.auth.infra; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +@Profile("local") +@Repository +@RequiredArgsConstructor +public class LocalDummyUserRepository { + + private static final long DUMMY_USER_ID = 11111L; + private static final String DUMMY_EMAIL = "test@example.com"; + private static final String DUMMY_PASSWORD_HASH = "hashed_pw"; + private static final String DUMMY_NICKNAME = "TestUser"; + private static final long INITIAL_CRYSTAL_BALANCE = 1_000L; + private static final int INITIAL_FREE_TURN_BALANCE = 3; + private static final int INITIAL_PAID_TURN_BALANCE = 1_000; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public Long createIfAbsent() { + MapSqlParameterSource parameters = new MapSqlParameterSource() + .addValue("userId", DUMMY_USER_ID) + .addValue("email", DUMMY_EMAIL) + .addValue("passwordHash", DUMMY_PASSWORD_HASH) + .addValue("nickname", DUMMY_NICKNAME) + .addValue("interestCode", InterestCode.ROUTINE.name()) + .addValue("crystalBalance", INITIAL_CRYSTAL_BALANCE) + .addValue("freeTurnBalance", INITIAL_FREE_TURN_BALANCE) + .addValue("paidTurnBalance", INITIAL_PAID_TURN_BALANCE); + + jdbcTemplate.update(""" + INSERT INTO users ( + id, + email, + password_hash, + nickname, + profile_image_key, + default_profile_type, + signup_status, + registered_at, + created_at, + updated_at + ) + VALUES ( + :userId, + :email, + :passwordHash, + :nickname, + NULL, + 'DEFAULT', + 'PROFILE_INCOMPLETE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING + """, parameters); + + jdbcTemplate.update(""" + INSERT INTO user_interests (user_id, interest_id) + SELECT :userId, id + FROM interests + WHERE code = :interestCode + ON CONFLICT (user_id) DO NOTHING + """, parameters); + + jdbcTemplate.update(""" + INSERT INTO user_wallets (user_id, crystal_balance) + VALUES (:userId, :crystalBalance) + ON CONFLICT (user_id) DO NOTHING + """, parameters); + + jdbcTemplate.update(""" + INSERT INTO ask_chat_wallets ( + user_id, + free_turn_balance, + paid_turn_balance, + version, + created_at, + updated_at + ) + VALUES ( + :userId, + :freeTurnBalance, + :paidTurnBalance, + 0, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (user_id) DO NOTHING + """, parameters); + + return DUMMY_USER_ID; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java index 87821f98..5818b3ab 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java @@ -41,8 +41,6 @@ @RequiredArgsConstructor public class DailyReportService { - private static final String DAILY_REPORT_LLM_MODEL = "GPT_4_O_MINI"; - private final UserRepository userRepository; private final DailyQuestionRepository dailyQuestionRepository; private final UserDailyQuestionRepository userDailyQuestionRepository; @@ -96,7 +94,7 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq prep.reportId(), ReportGenerationStep.DAILY_GENERATE, LlmProvider.OPENAI, - DAILY_REPORT_LLM_MODEL + dailyReportLlmClient.model() ); AiDailyReportResultDto dto; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/properties/DailyReportLlmProperties.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/properties/DailyReportLlmProperties.java new file mode 100644 index 00000000..2c0b4a0c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/properties/DailyReportLlmProperties.java @@ -0,0 +1,40 @@ +package com.devkor.ifive.nadab.domain.dailyreport.core.properties; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "daily-report.llm") +public class DailyReportLlmProperties { + + @NotBlank + private String model = "gpt-4o-mini"; + + @DecimalMin("0.0") + @DecimalMax("2.0") + private double temperature = 0.3; + + @Min(1) + private int maxOutputTokens = 512; + + @NotNull + private TokenLimitParameter tokenLimitParameter = TokenLimitParameter.MAX_TOKENS; + + private String reasoningEffort; + + public enum TokenLimitParameter { + MAX_TOKENS, + MAX_COMPLETION_TOKENS + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java index 9b72f962..019e81d9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto; import com.devkor.ifive.nadab.domain.dailyreport.core.dto.LlmDailyResultDto; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties; import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; import com.devkor.ifive.nadab.global.core.prompt.daily.DailyReportPromptLoader; import com.devkor.ifive.nadab.global.core.response.ErrorCode; @@ -22,7 +23,6 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.content.Media; import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.stereotype.Component; import org.springframework.util.MimeType; import org.springframework.util.MimeTypeUtils; @@ -37,6 +37,7 @@ public class DailyReportLlmClient { private final ObjectMapper objectMapper; private final LlmRouter llmRouter; private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final DailyReportLlmProperties properties; private final LlmProvider provider = LlmProvider.OPENAI; @@ -54,11 +55,7 @@ public LlmGenerationResult generate(String question, Ans ChatClient chatClient = llmRouter.route(provider); - OpenAiChatOptions options = OpenAiChatOptions.builder() - .model(OpenAiApi.ChatModel.GPT_4_O_MINI) - .temperature(0.3) - .maxTokens(512) - .build(); + OpenAiChatOptions options = buildOptions(); UserMessage userMessage = buildUserMessage(prompt, withImagePrompt,answerEntry); @@ -106,6 +103,27 @@ public LlmGenerationResult generate(String question, Ans } } + public String model() { + return properties.getModel(); + } + + private OpenAiChatOptions buildOptions() { + var builder = OpenAiChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()); + + switch (properties.getTokenLimitParameter()) { + case MAX_TOKENS -> builder.maxTokens(properties.getMaxOutputTokens()); + case MAX_COMPLETION_TOKENS -> builder.maxCompletionTokens(properties.getMaxOutputTokens()); + } + + if (!isBlank(properties.getReasoningEffort())) { + builder.reasoningEffort(properties.getReasoningEffort()); + } + + return builder.build(); + } + private String extractContent(ChatResponse response) { if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { return null; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java index ad58a18a..fdfa4451 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/report/ReportNotificationEventListener.java @@ -7,6 +7,7 @@ import com.devkor.ifive.nadab.domain.notification.application.NotificationCommandService; import com.devkor.ifive.nadab.domain.notification.core.entity.NotificationType; import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportCompletedEvent; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository; import com.devkor.ifive.nadab.domain.typereport.application.event.TypeReportCompletedEvent; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; @@ -33,6 +34,7 @@ * - 리포트 제작 가능 → 사용자에게 제작 가능 알림 * - 일일 리포트 완성 → 유형 리포트 제작 가능 여부 체크 및 알림 * - PDF 내보내기 완성 → 사용자에게 완성 알림 + * - PDF 내보내기 실패 → 사용자에게 실패·환불 알림 * - @Async로 비동기 처리 (리포트 생성 스레드와 분리) */ @Component @@ -189,6 +191,42 @@ public void handlePdfExportCompleted(PdfExportCompletedEvent event) { } } + /** + * PDF 내보내기 실패 알림 + * - 실패 확정·환불이 커밋된 뒤 호출자(렌더 리스너·복구 스케줄러)가 발행하는 PdfExportFailedEvent 를 받아 알림 생성 + * - 이벤트는 트랜잭션 밖(failAndRefund 이후)에서 발행되므로 @EventListener(완성 알림과 동일), AFTER_COMMIT 아님 + * - 클릭 시 실패 화면 이동 = FCM data(type=PDF_EXPORT_FAILED, targetId=jobId)로 FE 라우팅 + * - 실패 화면이 쓰는 기간·유형은 GET /pdf-exports/{jobId} 가 내려준다 + */ + @Async("notificationTaskExecutor") + @EventListener + public void handlePdfExportFailed(PdfExportFailedEvent event) { + try { + NotificationContent content = messageFactory.createMessage( + NotificationType.PDF_EXPORT_FAILED, + Map.of() + ); + + String idempotencyKey = String.format("PDF_EXPORT_FAILED_%d", event.getJobId()); + notificationCommandService.sendNotification( + event.getUserId(), + NotificationType.PDF_EXPORT_FAILED, + content.title(), + content.body(), + content.inboxMessage(), + event.getJobId().toString(), + idempotencyKey + ); + + log.debug("PDF export failed notification created: jobId={}, userId={}", + event.getJobId(), event.getUserId()); + + } catch (Exception e) { + log.error("Failed to handle PDF export failed event: jobId={}, error={}", + event.getJobId(), e.getMessage(), e); + } + } + // ========== 리포트 제작 가능 알림 ========== /** diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java index 4f881f55..9f9317fe 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java @@ -20,6 +20,7 @@ public enum NotificationType { MONTHLY_REPORT_AVAILABLE("월간 리포트 제작 가능", NotificationGroup.REPORT), TYPE_REPORT_AVAILABLE("유형 리포트 제작 가능", NotificationGroup.REPORT), PDF_EXPORT_COMPLETED("PDF 내보내기 완성", NotificationGroup.REPORT), + PDF_EXPORT_FAILED("PDF 내보내기 실패", NotificationGroup.REPORT), // 소셜 알림 FRIEND_REQUEST_RECEIVED("친구 요청", NotificationGroup.SOCIAL), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java index af202f98..82689616 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/PdfExportController.java @@ -55,7 +55,7 @@ public class PdfExportController { 생성 로딩 화면의 "포함 내용" 개수(답변/주간/월간)는 이 API가 반환하지 않습니다 — 방금 생성 직전 호출한 미리보기(GET /pdf-exports/preview) 응답값을 그대로 표시하면 됩니다(생성 직후엔 그 값이 곧 생성 대상 개수). 화면을 벗어났다 다시 들어오는 재진입 흐름에서는 GET /pdf-exports/current가 같은 개수를 함께 내려줍니다.
동시 생성 1개: 한 사용자는 생성 중(PENDING/IN_PROGRESS) 작업을 동시에 1개만 가질 수 있습니다. 생성 중인데 다른 유형·기간으로 다시 호출하면 409(PDF_EXPORT_ALREADY_IN_PROGRESS)로 거부되며, 응답 data에 이미 생성 중인 작업의 jobId가 담겨 옵니다 — 그 작업의 생성 화면으로 유도하고 상세·포함 개수는 GET /pdf-exports/current로 받으세요.
멱등 재사용: 같은 유형·기간의 작업이 아직 생성 중일 때 다시 호출하면(응답을 못 받아 재시도하거나 버튼 더블탭 등) 재과금 없이 그 작업을 그대로 돌려주며 balanceAfter=null 입니다(이중 과금 없음, 지갑 추가 차감 표시 금지). 완료(COMPLETED)된 작업은 재사용하지 않으므로 같은 기간 재요청은 새 작업으로 재과금됩니다(= 재생성).
- 생성 실패는 아카이브에도 안 뜨고 완료 푸시도 없어, 오직 폴링(GET /pdf-exports/{jobId})의 status=FAILED(errorCode 포함)로만 드러납니다. 그래서 로딩 화면에서 폴링 중이라면 FAILED를 COMPLETED와 같은 '종료 상태'로 처리해야 합니다(안 그러면 "생성 중"이 무한히 돕니다). 화면을 벗어난 뒤라면 따로 확인할 필요가 없습니다 — 차감 크리스탈은 자동 환불되고(지갑 이력에 남음) 산출물이 없어 사용자가 할 액션이 없습니다. 잔액을 표시 중이면 다시 조회해 갱신하세요.
+ 생성 실패는 아카이브에 뜨지 않습니다. 로딩 화면에서 폴링 중이라면 FAILED가 나오면 COMPLETED와 마찬가지로 폴링을 멈춥니다(FAILED를 종료로 처리하지 않으면 "생성 중"이 무한히 돕니다). 화면을 벗어난 뒤에 실패한 경우에는 실패 푸시(data type=PDF_EXPORT_FAILED, targetId=jobId)가 갑니다. 그걸 누르면 그 jobId로 GET /pdf-exports/{jobId}를 호출해 기간·유형을 받고, 그 기간으로 GET /pdf-exports/preview를 한 번 더 호출해 포함 내용 개수까지 채워 실패 화면을 구성합니다(상세는 GET /pdf-exports/{jobId} 설명 참조). 어느 쪽이든 차감 크리스탈은 자동 환불되며(지갑 이력에 남음), 잔액을 표시 중이면 다시 조회해 갱신하세요.
내보낼 답변/리포트가 하나도 없으면 PDF_EXPORT_NO_DATA로 거부됩니다 — 미리보기(GET /pdf-exports/preview)로 사전 확인을 권장합니다. """, security = @SecurityRequirement(name = "bearerAuth"), @@ -162,6 +162,7 @@ public ResponseEntity> getCurrentPdfExp summary = "PDF 내보내기 미리보기(포함 개수)", description = """ 해당 기간에 포함될 답변·주간 리포트·월간 리포트 개수를 돌려줍니다. 생성/차감 전 확인 팝업에서 "무엇이 몇 개 포함되는지" 보여주는 용도의 순수 조회입니다.
+ 실패 화면에서도 재사용합니다: 실패 알림을 눌러 진입하면 로컬 카운트가 없으므로, GET /pdf-exports/{jobId} 로 받은 기간(startDate·endDate)을 그대로 넣어 이 API를 호출해 "포함 내용" 개수를 채웁니다.
유형과 무관하게 3종 개수를 모두 내려주므로, 선택한 유형에 맞는 값만 표시하면 됩니다(예: 답변만 선택 시 answerCount).
세 개수가 모두 0이면 생성해도 빈 PDF라 생성 API가 PDF_EXPORT_NO_DATA로 거부합니다. 이 경우 생성 버튼을 비활성화하거나 기획에 맞게 팝업을 띄우면 됩니다.
기간 규칙은 생성 API와 동일합니다(시작일 ≤ 종료일, 종료일 ≤ 오늘, 최대 1년) — 잘못된 기간이면 PDF_EXPORT_INVALID_PERIOD. @@ -204,6 +205,9 @@ public ResponseEntity> getPdfExportPrev FAILED일 때 errorCode 값(둘 다 자동 환불됨):
- PDF_EXPORT_GENERATION_FAILED: 생성 도중 오류로 실패
- PDF_EXPORT_GENERATION_TIMEOUT: 생성이 60분 안에 끝나지 않아 자동 취소(배포·서버 재시작 등으로 작업이 유실된 경우)
+ 응답에는 유형(type)·기간(startDate~endDate)도 함께 담깁니다. 로딩/실패 화면의 기간 표시, 차감 크리스탈(유형에서 유도: 리포트만/답변만 50, 둘 다 100), "다시 PDF 생성하기" 버튼의 조건 프리필에 쓰세요. 화면에 머물러 폴링 중이라면 이미 알고 있는 값이지만, 실패 알림을 눌러 들어온 경우(앱이 종료돼 있었다면 로컬 상태가 없음)에는 이 API가 유일한 출처입니다.
+ 실패 알림(FCM): 생성이 실패해 환불되면 푸시가 발송됩니다(data type=PDF_EXPORT_FAILED, targetId=jobId). 이걸 눌러 들어오면 그 jobId로 이 API를 호출해 실패 화면을 구성합니다. FAILED는 아카이브(COMPLETED만)와 GET /pdf-exports/current(진행 중만) 어디에도 나오지 않으므로, jobId 없이 실패 작업을 찾을 방법은 없습니다.
+ "포함 내용" 개수(답변/주간/월간)는 이 응답에 없습니다 — 정적값이라 폴링마다 싣지 않습니다. 얻는 방법: 생성 직후에는 미리보기(GET /pdf-exports/preview)에서 받은 값을, 진행 중 재진입은 GET /pdf-exports/current가 함께 내려주는 값을 그대로 쓰세요. 실패 알림을 눌러 실패 화면으로 콜드 스타트 진입한 경우(앱이 종료돼 있어 로컬 값이 없음)에는, 이 응답의 기간(startDate·endDate)으로 GET /pdf-exports/preview?startDate=..&endDate=.. 를 한 번 더 호출해 개수를 받으세요.
다운로드 URL은 이 응답에 포함되지 않습니다. COMPLETED가 된 뒤 POST /pdf-exports/{jobId}/download-url 로 발급받으세요(발급 빈도 제한이 폴링에 영향을 주지 않도록 분리되어 있습니다). """, security = @SecurityRequirement(name = "bearerAuth"), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java index 4d7d81dd..88f224ad 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/api/dto/response/PdfExportStatusResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; import java.time.OffsetDateTime; public record PdfExportStatusResponse( @@ -11,6 +12,16 @@ public record PdfExportStatusResponse( @Schema(description = "작업 상태 (PENDING/IN_PROGRESS/COMPLETED/FAILED)", example = "COMPLETED") String status, + @Schema(description = "내보내기 유형 (REPORT_ONLY/ANSWER_ONLY/REPORT_AND_ANSWER). 차감 크리스탈은 여기서 유도(리포트만/답변만 50, 둘 다 100)", + example = "REPORT_AND_ANSWER") + String type, + + @Schema(description = "기간 시작일", example = "2025-11-01") + LocalDate startDate, + + @Schema(description = "기간 종료일", example = "2025-11-30") + LocalDate endDate, + @Schema(description = "COMPLETED 시 다운로드 보관 만료 시각(완료 시각 + 7일). 그 외 null", example = "2025-11-08T05:30:00Z") OffsetDateTime expiresAt, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java index f1865f11..8e6a66b5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportQueryService.java @@ -44,12 +44,15 @@ public class PdfExportQueryService { private static final List ACTIVE_STATUSES = List.of(PdfExportStatus.PENDING, PdfExportStatus.IN_PROGRESS); - /** 폴링용 상태 조회. 다운로드 발급은 별도 엔드포인트. */ + /** 폴링용 상태 조회(상태·유형·기간). 다운로드 발급은 별도 엔드포인트. */ public PdfExportStatusResponse getStatus(Long userId, Long jobId) { PdfExportJob job = findOwnedJob(userId, jobId); return new PdfExportStatusResponse( job.getId(), job.getStatus().name(), + job.getType().name(), + job.getStartDate(), + job.getEndDate(), job.getExpiresAt(), job.isDownloadExpired(), job.getErrorCode()); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java index b7480425..9e03d3d8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportTxService.java @@ -86,14 +86,20 @@ public PdfExportReserveResultDto reserveAndPublish(User user, PdfExportType type return new PdfExportReserveResultDto(jobId, balanceAfter); } - public void confirm(Long jobId, Long logId) { + /** + * 완료 확정. 반환값은 이 호출이 실제로 확정했는지다. + * 호출자(렌더 리스너)는 이 값이 true 일 때만 완료 알림을 발행한다. + * false 는 CAS 경합에서 져 아무것도 안 한 경우로, 그때 알리면 이미 실패·환불된 작업에 "PDF가 완성됐어요" 푸시가 나간다. + */ + public boolean confirm(Long jobId, Long logId) { // 진행 중일 때만 성공(1). 0이면 그 사이 다른 곳(복구 스윕)에서 이미 실패·환불된 job이라 확정하지 않는다. if (pdfExportJobRepository.markCompleted(jobId) == 0) { log.warn("[PDF_EXPORT][CONFIRM_SKIPPED] jobId={} — 이미 실패·환불 처리된 작업이라 확정하지 않는다", jobId); - return; + return false; } crystalLogRepository.markConfirmed(logId); dedupPreviousCompleted(jobId); + return true; } /** @@ -127,12 +133,17 @@ public void afterCommit() { }); } - public void failAndRefund(Long userId, Long jobId, Long logId, String errorCode) { + /** + * 실패 확정 + 환불. 반환값은 이 호출이 실제로 환불했는지다. + * 호출자(렌더 리스너·복구 스케줄러)는 이 값이 true 일 때만 실패 알림을 발행한다. + * false 는 CAS 경합에서 져 아무것도 안 한 경우로, 그때 알리면 이미 완료된 작업에 "생성에 실패했어요" 푸시가 나간다. + */ + public boolean failAndRefund(Long userId, Long jobId, Long logId, String errorCode) { // 진행 중일 때만 성공(1)한 호출만 환불을 책임진다. 0이면 이미 완료·환불된 job → 공짜 PDF·이중 환불 방지. if (pdfExportJobRepository.markFailed(jobId, errorCode) == 0) { log.warn("[PDF_EXPORT][REFUND_SKIPPED] jobId={}, errorCode={} — 이미 완료·환불된 작업이라 환불하지 않는다", jobId, errorCode); - return; + return false; } // 실제 차감된 값(CrystalLog.delta, 음수)을 그대로 되돌린다 — 로그에서 읽어 가격이 바뀌어도 차감액=환불액 보장. @@ -148,5 +159,6 @@ public void failAndRefund(Long userId, Long jobId, Long logId, String errorCode) crystalLogRepository.markRefunded(logId); log.warn("[PDF_EXPORT][REFUNDED] jobId={}, userId={}, refund={}, errorCode={}", jobId, userId, refundAmount, errorCode); + return true; } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/event/PdfExportFailedEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/event/PdfExportFailedEvent.java new file mode 100644 index 00000000..0d5d5497 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/event/PdfExportFailedEvent.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.pdfexport.application.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * PDF 내보내기 실패 FCM 알림 트리거. + * - failAndRefund 로 모이는 두 경로(렌더 리스너·복구 스케줄러)가 그 반환값이 true(=이 호출이 실제로 환불)일 때만 트랜잭션 커밋 후 발행한다. + * - 경합에서 진 호출은 이미 완료·환불된 job 이라 발행하지 않는다. + */ +@Getter +@RequiredArgsConstructor +public class PdfExportFailedEvent { + + private final Long jobId; + private final Long userId; +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java index c7849024..0b84338d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListener.java @@ -7,6 +7,7 @@ import com.devkor.ifive.nadab.domain.pdfexport.application.helper.PdfPhotoPrefetcher; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportCompletedEvent; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfHtmlAssembler; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfImage; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfRenderer; @@ -130,16 +131,23 @@ public void handle(PdfExportRequestedEventDto event) { storage.upload(resultKey, pdfFile, PdfExportFileNames.downloadFileName(job), PdfExportFileNames.asciiFallbackFileName(job)); - txService.confirm(jobId, crystalLogId); - // ── 4) 완료 이벤트(FCM 알림용). 확정 이후라 여기서 실패해도 실패로 되돌리지 않는다 ── - notifyCompleted(jobId, userId); + // ── 4) 완료 이벤트(FCM 알림용). 실제로 확정한 경우에만 알린다. + // false 면 복구 스윕이 먼저 실패·환불한 것이라, 여기서 알리면 이미 실패 처리된 작업에 완성 푸시가 나간다. + if (txService.confirm(jobId, crystalLogId)) { + notifyCompleted(jobId, userId); + } } catch (Exception e) { // 렌더·업로드·confirm 어디서 실패해도: 실패 확정 + 환불(별도 Tx). CAS 가드로 이중 환불·공짜 PDF 방지. // error_code엔 안전한 ErrorCode enum 이름만 저장 — 예외 메시지·스택은 클라(getStatus)에 노출 금지. log.error("[PDF_EXPORT][GENERATION_FAILED] jobId={}, userId={}", jobId, userId, e); - txService.failAndRefund(userId, jobId, crystalLogId, ErrorCode.PDF_EXPORT_GENERATION_FAILED.name()); + if (txService.failAndRefund(userId, jobId, crystalLogId, + ErrorCode.PDF_EXPORT_GENERATION_FAILED.name())) { + // 실제로 환불한 경우에만 알린다. + // false 면 복구 스윕이 먼저 처리한 것이라, 여기서 또 알리면 실패 푸시가 중복된다. + notifyFailed(jobId, userId); + } } finally { // 업로드 성공·실패 무관 로컬 임시파일 정리(S3에 올라간 뒤엔 불필요, 실패 시엔 잔여 제거). if (pdfFile != null) { @@ -166,6 +174,19 @@ private void notifyCompleted(Long jobId, Long userId) { } } + /** + * 실패 알림 발행. 이 시점엔 실패 확정·환불이 이미 커밋된 뒤다. + * 예외가 새면 위 catch 밖으로 나가 원인 예외를 덮으므로 여기서 삼킨다. + */ + private void notifyFailed(Long jobId, Long userId) { + try { + eventPublisher.publishEvent(new PdfExportFailedEvent(jobId, userId)); + } catch (Exception e) { + log.warn("[PDF_EXPORT][NOTIFY_FAILED] jobId={}, userId={} — 환불은 정상 처리됐고 알림만 실패했다", + jobId, userId, e); + } + } + /** 결과 임시파일 삭제(실패해도 삼킨다). */ private void deleteQuietly(Path file) { try { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java index f684a8d9..de46de29 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoveryScheduler.java @@ -1,11 +1,13 @@ package com.devkor.ifive.nadab.domain.pdfexport.application.scheduler; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.core.entity.PdfExportJob; import com.devkor.ifive.nadab.domain.pdfexport.core.repository.PdfExportJobRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -30,6 +32,7 @@ public class PdfExportRecoveryScheduler { private final PdfExportJobRepository pdfExportJobRepository; private final PdfExportTxService txService; + private final ApplicationEventPublisher eventPublisher; @Scheduled(fixedDelay = 5 * 60 * 1000) public void recoverStuckJobs() { @@ -64,9 +67,14 @@ private boolean refundQuietly(PdfExportJob job, List failed) { Long jobId = job.getId(); try { Long userId = job.getUser().getId(); // LAZY 프록시 id 접근 — 쿼리 없음 - txService.failAndRefund(userId, jobId, job.getCrystalLogId(), + boolean refunded = txService.failAndRefund(userId, jobId, job.getCrystalLogId(), ErrorCode.PDF_EXPORT_GENERATION_TIMEOUT.name()); - return true; + if (refunded) { + // 이 경로는 정의상 사용자가 생성 화면을 떠난 뒤다(최소 60분 경과) — 알림이 유일한 통보 수단이다. + notifyFailed(jobId, userId); + } + // false 면 그 사이 렌더 리스너가 먼저 정리한 것이라 우리가 회수한 건이 아니다. + return refunded; } catch (Exception e) { if (failed.isEmpty()) { log.error("[PDF_EXPORT][RECOVERY] 개별 환불 실패: jobId={}", jobId, e); @@ -75,4 +83,16 @@ private boolean refundQuietly(PdfExportJob job, List failed) { return false; } } + + /** + * 실패 알림 발행. 환불은 이미 커밋된 뒤다. + * 예외가 새면 위 catch 가 환불 성공을 실패로 집계하므로 여기서 삼킨다. + */ + private void notifyFailed(Long jobId, Long userId) { + try { + eventPublisher.publishEvent(new PdfExportFailedEvent(jobId, userId)); + } catch (Exception e) { + log.warn("[PDF_EXPORT][RECOVERY] 알림 발행 실패: jobId={} — 환불은 정상 처리됐다", jobId, e); + } + } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java index a08cb4ed..435c4b25 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java @@ -1,7 +1,10 @@ package com.devkor.ifive.nadab.domain.stats.application; +import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.DailyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -18,6 +21,7 @@ public class DailyStatsService { private final DailyStatsRepository repo; + private final PeakStatsTracker peakStatsTracker; private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = @@ -57,6 +61,16 @@ public DailyStatsViewModel getDailyStatsLast7Days() { List assignedCounts = days.stream().map(d -> assignedMap.getOrDefault(d, 0L)).toList(); List completedCounts = days.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList(); + PeakStatViewModel signupPeak = peakStatsTracker.updateAndGet( + PeakMetric.DAILY_SIGNUP, days, signupCounts, today + ); + PeakStatViewModel assignedQuestionPeak = peakStatsTracker.updateAndGet( + PeakMetric.DAILY_ASSIGNED_QUESTION, days, assignedCounts, today + ); + PeakStatViewModel dauPeak = peakStatsTracker.updateAndGet( + PeakMetric.DAU, days, completedCounts, today + ); + long sharedNow = repo.countSharedDailyReportsNow(); return new DailyStatsViewModel( @@ -64,8 +78,11 @@ public DailyStatsViewModel getDailyStatsLast7Days() { signupCounts, assignedCounts, completedCounts, + signupPeak, + assignedQuestionPeak, + dauPeak, sharedNow, OffsetDateTime.now(SEOUL).format(FMT) ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java index 46e93e30..c65a392a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java @@ -1,7 +1,10 @@ package com.devkor.ifive.nadab.domain.stats.application; +import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -22,6 +25,7 @@ public class MonthlyStatsService { private final MonthlyStatsRepository repo; + private final PeakStatsTracker peakStatsTracker; private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = @@ -53,8 +57,11 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { Map assignedMap = aggregateByMonth( repo.findAssignedQuestionCountsByDateBetween(startDate, endDateInclusive) ); - Map completedMap = aggregateByMonth( - repo.findCompletedMonthlyReportCountsByDateBetween(startDate, endDateInclusive) + Map completedV1Map = aggregateByMonth( + repo.findCompletedMonthlyReportV1CountsByDateBetween(startDate, endDateInclusive) + ); + Map completedV2Map = aggregateByMonth( + repo.findCompletedMonthlyReportV2CountsByDateBetween(startDate, endDateInclusive) ); Map completedDailyMap = aggregateByMonth( repo.findCompletedDailyReportCountsByDateBetween(startDate, endDateInclusive) @@ -66,19 +73,50 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { List signupCounts = months.stream().map(m -> signupMap.getOrDefault(m, 0L)).toList(); List assignedCounts = months.stream().map(m -> assignedMap.getOrDefault(m, 0L)).toList(); List completedDailyCounts = months.stream().map(m -> completedDailyMap.getOrDefault(m, 0L)).toList(); - List completedCounts = months.stream().map(m -> completedMap.getOrDefault(m, 0L)).toList(); + List completedV1Counts = months.stream().map(m -> completedV1Map.getOrDefault(m, 0L)).toList(); + List completedV2Counts = months.stream().map(m -> completedV2Map.getOrDefault(m, 0L)).toList(); + List completedTotalCounts = sumCounts(completedV1Counts, completedV2Counts); List mauCounts = months.stream().map(m -> mauMap.getOrDefault(m, 0L)).toList(); - long inProgressNow = repo.countInProgressMonthlyReportsNow(); + List monthStarts = months.stream().map(month -> month.atDay(1)).toList(); + LocalDate currentMonthStart = currentMonth.atDay(1); + PeakStatViewModel signupPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_SIGNUP, monthStarts, signupCounts, currentMonthStart + ); + PeakStatViewModel assignedQuestionPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_ASSIGNED_QUESTION, monthStarts, assignedCounts, currentMonthStart + ); + PeakStatViewModel completedDailyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_DAILY_REPORT, monthStarts, completedDailyCounts, currentMonthStart + ); + PeakStatViewModel completedMonthlyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_REPORT, monthStarts, completedTotalCounts, currentMonthStart + ); + PeakStatViewModel mauPeak = peakStatsTracker.updateAndGet( + PeakMetric.MAU, monthStarts, mauCounts, currentMonthStart + ); + + long inProgressV1Now = repo.countInProgressMonthlyReportV1Now(); + long inProgressV2Now = repo.countInProgressMonthlyReportV2Now(); + long inProgressTotalNow = inProgressV1Now + inProgressV2Now; return new MonthlyStatsViewModel( labels, signupCounts, assignedCounts, completedDailyCounts, - completedCounts, + completedV1Counts, + completedV2Counts, + completedTotalCounts, mauCounts, - inProgressNow, + signupPeak, + assignedQuestionPeak, + completedDailyReportPeak, + completedMonthlyReportPeak, + mauPeak, + inProgressV1Now, + inProgressV2Now, + inProgressTotalNow, OffsetDateTime.now(SEOUL).format(FMT) ); } @@ -91,4 +129,12 @@ private Map aggregateByMonth(List dailyCounts) { } return map; } + + private List sumCounts(List first, List second) { + List totals = new ArrayList<>(first.size()); + for (int i = 0; i < first.size(); i++) { + totals.add(first.get(i) + second.get(i)); + } + return totals; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializer.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializer.java new file mode 100644 index 00000000..855f6811 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializer.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsRepository; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PeakStatsInitializer implements ApplicationRunner { + + private final PeakStatsRepository peakStatsRepository; + private final PeakStatsStore peakStatsStore; + + @Override + public void run(ApplicationArguments args) { + try { + var peaks = peakStatsRepository.findAllPeakStats(); + peaks.forEach(peakStatsStore::updateIfGreater); + log.info("Peak statistics initialized: {} metrics", peaks.size()); + } catch (RuntimeException e) { + log.error("Failed to initialize peak statistics", e); + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java index f1594379..5b67a51a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java @@ -1,6 +1,9 @@ package com.devkor.ifive.nadab.domain.stats.application; +import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; @@ -23,6 +26,7 @@ public class WeeklyStatsService { private final WeeklyStatsRepository repo; + private final PeakStatsTracker peakStatsTracker; private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = @@ -68,6 +72,22 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { List completedCounts = weekStarts.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList(); List wauCounts = weekStarts.stream().map(d -> wauMap.getOrDefault(d, 0L)).toList(); + PeakStatViewModel signupPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_SIGNUP, weekStarts, signupCounts, currentWeekStart + ); + PeakStatViewModel assignedQuestionPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_ASSIGNED_QUESTION, weekStarts, assignedCounts, currentWeekStart + ); + PeakStatViewModel completedDailyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_DAILY_REPORT, weekStarts, completedDailyCounts, currentWeekStart + ); + PeakStatViewModel completedWeeklyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_REPORT, weekStarts, completedCounts, currentWeekStart + ); + PeakStatViewModel wauPeak = peakStatsTracker.updateAndGet( + PeakMetric.WAU, weekStarts, wauCounts, currentWeekStart + ); + long inProgressNow = repo.countInProgressWeeklyReportsNow(); return new WeeklyStatsViewModel( @@ -77,6 +97,11 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { completedDailyCounts, completedCounts, wauCounts, + signupPeak, + assignedQuestionPeak, + completedDailyReportPeak, + completedWeeklyReportPeak, + wauPeak, inProgressNow, OffsetDateTime.now(SEOUL).format(FMT) ); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTracker.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTracker.java new file mode 100644 index 00000000..6433c713 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTracker.java @@ -0,0 +1,58 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PeakStatsTracker { + + private final PeakStatsStore peakStatsStore; + + public PeakStatViewModel updateAndGet( + PeakMetric metric, + List periodStarts, + List values, + LocalDate currentPeriodStart + ) { + if (periodStarts.size() != values.size()) { + throw new IllegalArgumentException("periodStarts and values must have the same size"); + } + + PeakStat candidate = findPeakCandidate(periodStarts, values); + if (candidate != null) { + peakStatsStore.updateIfGreater(metric, candidate); + } + + return peakStatsStore.find(metric) + .map(peak -> PeakStatViewModel.from(peak, metric.periodUnit(), currentPeriodStart)) + .orElseGet(PeakStatViewModel::empty); + } + + private PeakStat findPeakCandidate(List periodStarts, List values) { + PeakStat candidate = null; + + for (int i = 0; i < periodStarts.size(); i++) { + long value = values.get(i); + LocalDate periodStart = periodStarts.get(i); + + if (value <= 0) { + continue; + } + if (candidate == null + || value > candidate.value() + || value == candidate.value() && periodStart.isAfter(candidate.periodStart())) { + candidate = new PeakStat(value, periodStart); + } + } + + return candidate; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java index 80c6f032..00776dfc 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.dto.daily; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; + import java.util.List; public record DailyStatsViewModel( @@ -7,6 +9,9 @@ public record DailyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, + PeakStatViewModel signupPeak, + PeakStatViewModel assignedQuestionPeak, + PeakStatViewModel dauPeak, long sharedDailyReportCount, String refreshedAt // "2026-02-27 21:34:12" ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java index 47ecf510..0abca917 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.dto.monthly; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; + import java.util.List; public record MonthlyStatsViewModel( @@ -7,8 +9,17 @@ public record MonthlyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, - List completedMonthlyReportCounts, + List completedMonthlyReportV1Counts, + List completedMonthlyReportV2Counts, + List completedMonthlyReportTotalCounts, List mauCounts, - long inProgressMonthlyReportCount, + PeakStatViewModel signupPeak, + PeakStatViewModel assignedQuestionPeak, + PeakStatViewModel completedDailyReportPeak, + PeakStatViewModel completedMonthlyReportPeak, + PeakStatViewModel mauPeak, + long inProgressMonthlyReportV1Count, + long inProgressMonthlyReportV2Count, + long inProgressMonthlyReportTotalCount, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakMetric.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakMetric.java new file mode 100644 index 00000000..706491f6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakMetric.java @@ -0,0 +1,27 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +public enum PeakMetric { + DAILY_SIGNUP(PeakPeriodUnit.DAY), + DAILY_ASSIGNED_QUESTION(PeakPeriodUnit.DAY), + DAU(PeakPeriodUnit.DAY), + WEEKLY_SIGNUP(PeakPeriodUnit.WEEK), + WEEKLY_ASSIGNED_QUESTION(PeakPeriodUnit.WEEK), + WEEKLY_DAILY_REPORT(PeakPeriodUnit.WEEK), + WEEKLY_REPORT(PeakPeriodUnit.WEEK), + WAU(PeakPeriodUnit.WEEK), + MONTHLY_SIGNUP(PeakPeriodUnit.MONTH), + MONTHLY_ASSIGNED_QUESTION(PeakPeriodUnit.MONTH), + MONTHLY_DAILY_REPORT(PeakPeriodUnit.MONTH), + MONTHLY_REPORT(PeakPeriodUnit.MONTH), + MAU(PeakPeriodUnit.MONTH); + + private final PeakPeriodUnit periodUnit; + + PeakMetric(PeakPeriodUnit periodUnit) { + this.periodUnit = periodUnit; + } + + public PeakPeriodUnit periodUnit() { + return periodUnit; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakPeriodUnit.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakPeriodUnit.java new file mode 100644 index 00000000..8fc997b0 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakPeriodUnit.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +public enum PeakPeriodUnit { + DAY, + WEEK, + MONTH +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStat.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStat.java new file mode 100644 index 00000000..7cd4b428 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStat.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +import java.time.LocalDate; +import java.util.Objects; + +public record PeakStat( + long value, + LocalDate periodStart +) { + + public PeakStat { + if (value < 0) { + throw new IllegalArgumentException("Peak value must not be negative"); + } + Objects.requireNonNull(periodStart, "periodStart must not be null"); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStatViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStatViewModel.java new file mode 100644 index 00000000..006b8e64 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStatViewModel.java @@ -0,0 +1,41 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public record PeakStatViewModel( + boolean available, + long value, + String periodStart, + String periodLabel, + boolean currentPeriod +) { + + private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + public static PeakStatViewModel from( + PeakStat peak, + PeakPeriodUnit periodUnit, + LocalDate currentPeriodStart + ) { + return new PeakStatViewModel( + true, + peak.value(), + peak.periodStart().toString(), + formatPeriodLabel(peak.periodStart(), periodUnit), + peak.periodStart().equals(currentPeriodStart) + ); + } + + public static PeakStatViewModel empty() { + return new PeakStatViewModel(false, 0L, "", "", false); + } + + private static String formatPeriodLabel(LocalDate periodStart, PeakPeriodUnit periodUnit) { + return switch (periodUnit) { + case DAY -> periodStart.toString(); + case WEEK -> periodStart + " ~ " + periodStart.plusDays(6); + case MONTH -> MONTH_FORMATTER.format(periodStart); + }; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java index d58a6851..5e79d50f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.dto.weekly; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; + import java.util.List; public record WeeklyStatsViewModel( @@ -9,6 +11,11 @@ public record WeeklyStatsViewModel( List completedDailyReportCounts, List completedWeeklyReportCounts, List wauCounts, + PeakStatViewModel signupPeak, + PeakStatViewModel assignedQuestionPeak, + PeakStatViewModel completedDailyReportPeak, + PeakStatViewModel completedWeeklyReportPeak, + PeakStatViewModel wauPeak, long inProgressWeeklyReportCount, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java index 1863a8ac..3d76aebf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java @@ -55,7 +55,7 @@ public List findAssignedQuestionCountsByDateBetween(LocalDate star .getResultList(); } - public List findCompletedMonthlyReportCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { + public List findCompletedMonthlyReportV1CountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { return em.createQuery(""" select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(mr.date, count(mr.id)) from MonthlyReport mr @@ -69,6 +69,20 @@ public List findCompletedMonthlyReportCountsByDateBetween(LocalDat .getResultList(); } + public List findCompletedMonthlyReportV2CountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(mr.date, count(mr.id)) + from MonthlyReportV2 mr + where mr.date between :startDate and :endDate + and mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.COMPLETED + group by mr.date + order by mr.date + """, DateCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) + .getResultList(); + } + public List findCompletedDailyReportCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { return em.createQuery(""" select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(dr.date, count(dr.id)) @@ -83,7 +97,7 @@ public List findCompletedDailyReportCountsByDateBetween(LocalDate .getResultList(); } - public long countInProgressMonthlyReportsNow() { + public long countInProgressMonthlyReportV1Now() { return em.createQuery(""" select count(mr.id) from MonthlyReport mr @@ -92,6 +106,15 @@ select count(mr.id) .getSingleResult(); } + public long countInProgressMonthlyReportV2Now() { + return em.createQuery(""" + select count(mr.id) + from MonthlyReportV2 mr + where mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.IN_PROGRESS + """, Long.class) + .getSingleResult(); + } + public List findMonthlyActiveUserCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { List rows = em.createQuery(""" select function('date_trunc', 'month', dr.date), count(distinct dr.answerEntry.user.id) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepository.java new file mode 100644 index 00000000..aef9e4b9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepository.java @@ -0,0 +1,154 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Repository +@RequiredArgsConstructor +public class PeakStatsRepository { + + private static final String FIND_ALL_PEAKS_QUERY = """ + with signup_daily as ( + select cast(u.registered_at at time zone 'Asia/Seoul' as date) as period_start, + count(*) as value + from users u + where u.signup_status = 'COMPLETED' + and u.registered_at is not null + and cast(u.registered_at at time zone 'Asia/Seoul' as date) + <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(u.registered_at at time zone 'Asia/Seoul' as date) + ), + assigned_daily as ( + select udq.date as period_start, + count(*) as value + from user_daily_questions udq + where udq.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by udq.date + ), + completed_daily_reports as ( + select dr.date as report_date, + ae.user_id + from daily_reports dr + join answer_entries ae on ae.id = dr.answer_entry_id + where dr.status = 'COMPLETED' + and dr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + ), + completed_monthly_reports as ( + select cast(date_trunc('month', mr.date) as date) as period_start, + count(*) as value + from monthly_reports mr + where mr.status = 'COMPLETED' + and mr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(date_trunc('month', mr.date) as date) + union all + select cast(date_trunc('month', mr.date) as date) as period_start, + count(*) as value + from monthly_reports_v2 mr + where mr.status = 'COMPLETED' + and mr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(date_trunc('month', mr.date) as date) + ), + metric_counts as ( + select 'DAILY_SIGNUP' as metric, period_start, value + from signup_daily + union all + select 'DAILY_ASSIGNED_QUESTION', period_start, value + from assigned_daily + union all + select 'DAU', report_date, count(distinct user_id) + from completed_daily_reports + group by report_date + union all + select 'WEEKLY_SIGNUP', cast(date_trunc('week', period_start) as date), sum(value) + from signup_daily + group by cast(date_trunc('week', period_start) as date) + union all + select 'WEEKLY_ASSIGNED_QUESTION', cast(date_trunc('week', period_start) as date), sum(value) + from assigned_daily + group by cast(date_trunc('week', period_start) as date) + union all + select 'WEEKLY_DAILY_REPORT', cast(date_trunc('week', report_date) as date), count(*) + from completed_daily_reports + group by cast(date_trunc('week', report_date) as date) + union all + select 'WEEKLY_REPORT', cast(date_trunc('week', wr.date) as date), count(*) + from weekly_reports wr + where wr.status = 'COMPLETED' + and wr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(date_trunc('week', wr.date) as date) + union all + select 'WAU', cast(date_trunc('week', report_date) as date), count(distinct user_id) + from completed_daily_reports + group by cast(date_trunc('week', report_date) as date) + union all + select 'MONTHLY_SIGNUP', cast(date_trunc('month', period_start) as date), sum(value) + from signup_daily + group by cast(date_trunc('month', period_start) as date) + union all + select 'MONTHLY_ASSIGNED_QUESTION', cast(date_trunc('month', period_start) as date), sum(value) + from assigned_daily + group by cast(date_trunc('month', period_start) as date) + union all + select 'MONTHLY_DAILY_REPORT', cast(date_trunc('month', report_date) as date), count(*) + from completed_daily_reports + group by cast(date_trunc('month', report_date) as date) + union all + select 'MONTHLY_REPORT', period_start, sum(value) + from completed_monthly_reports + group by period_start + union all + select 'MAU', cast(date_trunc('month', report_date) as date), count(distinct user_id) + from completed_daily_reports + group by cast(date_trunc('month', report_date) as date) + ), + ranked as ( + select metric, + period_start, + value, + row_number() over ( + partition by metric + order by value desc, period_start desc + ) as ranking + from metric_counts + where value > 0 + ) + select metric, period_start, value + from ranked + where ranking = 1 + order by metric + """; + + private final EntityManager em; + + public Map findAllPeakStats() { + List rows = em.createNativeQuery(FIND_ALL_PEAKS_QUERY).getResultList(); + EnumMap peaks = new EnumMap<>(PeakMetric.class); + + for (Object rawRow : rows) { + Object[] row = (Object[]) rawRow; + PeakMetric metric = PeakMetric.valueOf(row[0].toString()); + peaks.put(metric, new PeakStat(((Number) row[2]).longValue(), toLocalDate(row[1]))); + } + + return Map.copyOf(peaks); + } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + return LocalDate.parse(value.toString()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsStore.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsStore.java new file mode 100644 index 00000000..25cec17c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsStore.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; + +import java.util.Optional; + +public interface PeakStatsStore { + + Optional find(PeakMetric metric); + + /** + * Stores a positive candidate when it is greater than the current record. + * When values are equal, the more recent period is retained. + */ + void updateIfGreater(PeakMetric metric, PeakStat candidate); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStore.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStore.java new file mode 100644 index 00000000..ba784e83 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStore.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.domain.stats.infra; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static java.util.Objects.requireNonNull; + +@Component +public class InMemoryPeakStatsStore implements PeakStatsStore { + + private final ConcurrentMap peaks = new ConcurrentHashMap<>(); + + @Override + public Optional find(PeakMetric metric) { + return Optional.ofNullable(peaks.get(requireNonNull(metric, "metric must not be null"))); + } + + @Override + public void updateIfGreater(PeakMetric metric, PeakStat candidate) { + requireNonNull(metric, "metric must not be null"); + requireNonNull(candidate, "candidate must not be null"); + + if (candidate.value() == 0) { + return; + } + + peaks.compute(metric, (key, current) -> shouldReplace(current, candidate) ? candidate : current); + } + + private boolean shouldReplace(PeakStat current, PeakStat candidate) { + if (current == null || candidate.value() > current.value()) { + return true; + } + return candidate.value() == current.value() + && candidate.periodStart().isAfter(current.periodStart()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e97fd634..b6e4a0f6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -15,6 +15,14 @@ spring: ddl-auto: validate show-sql: false +daily-report: + llm: + model: gpt-5.6-luna + temperature: 1.0 + max-output-tokens: 512 + token-limit-parameter: MAX_COMPLETION_TOKENS + reasoning-effort: none + springdoc: api-docs: enabled: true diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa5b129b..950ec18d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -95,11 +95,18 @@ admin: api_prefix: /api/v1 +daily-report: + llm: + model: gpt-4o-mini + temperature: 0.3 + max-output-tokens: 512 + token-limit-parameter: MAX_TOKENS + ask-chat: answer: provider: OPENAI - model: gpt-4o-mini - temperature: 0.3 + model: gpt-5.6-luna + temperature: 1.0 max-tokens: 900 recent-message-limit: 10 follow-up-question-count: 2 diff --git a/src/main/resources/static/css/stats-peak.css b/src/main/resources/static/css/stats-peak.css new file mode 100644 index 00000000..2bd5caf1 --- /dev/null +++ b/src/main/resources/static/css/stats-peak.css @@ -0,0 +1,110 @@ +.chart-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 14px 20px; +} + +.chart-card-heading { + flex: 1 1 220px; + min-width: 0; +} + +.peak-record { + flex: 0 0 auto; + min-width: 220px; + min-height: 69px; + padding: 9px 11px 10px; + border: 1px solid rgba(245, 165, 36, .32); + border-radius: 10px; + background: linear-gradient(135deg, rgba(245, 165, 36, .11), rgba(245, 165, 36, .035)); + font-family: 'DM Mono', monospace; +} + +.peak-record-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; +} + +.peak-record-label { + display: inline-flex; + align-items: center; + gap: 6px; + color: #f5a524; + font-family: 'Syne', sans-serif; + font-size: .66rem; + font-weight: 700; + letter-spacing: .06em; +} + +.peak-record-label::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 2px; + background: #f5a524; + transform: rotate(45deg); + box-shadow: 0 0 7px rgba(245, 165, 36, .45); +} + +.peak-record-current { + padding: 2px 6px; + border: 1px solid rgba(77, 232, 194, .28); + border-radius: 999px; + background: rgba(77, 232, 194, .08); + color: var(--accent-3); + font-family: 'Syne', sans-serif; + font-size: .58rem; + font-weight: 700; + white-space: nowrap; +} + +.peak-record-detail { + display: block; +} + +.peak-record-value { + color: var(--text-1); + font-size: 1.16rem; + font-weight: 500; + line-height: 1.1; +} + +.peak-record-period { + display: block; + margin-top: 3px; + color: var(--text-2); + font-size: .66rem; + line-height: 1.35; + white-space: nowrap; +} + +.peak-record-no-data { + color: var(--text-3); + font-size: .72rem; +} + +.peak-record-empty { + border-color: var(--border); + background: rgba(255, 255, 255, .015); +} + +.peak-record-empty .peak-record-label { + color: var(--text-3); +} + +.peak-record-empty .peak-record-label::before { + background: var(--text-3); + box-shadow: none; +} + +@media (max-width: 560px) { + .peak-record { + width: 100%; + min-width: 0; + } +} diff --git a/src/main/resources/templates/admin/ask-chat-rag.html b/src/main/resources/templates/admin/ask-chat-rag.html index fdd678be..3482db00 100644 --- a/src/main/resources/templates/admin/ask-chat-rag.html +++ b/src/main/resources/templates/admin/ask-chat-rag.html @@ -117,10 +117,31 @@ .btn-row { display: flex; + align-items: end; gap: 8px; flex-wrap: wrap; } + .batch-run-control { + display: grid; + gap: 5px; + } + + .batch-run-control label { + color: var(--muted); + font-size: 12px; + } + + .batch-run-control input { + width: 110px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + padding: 8px 10px; + font-size: 13px; + } + .btn { border: 0; border-radius: 6px; @@ -209,7 +230,9 @@

Admin Console

Daily Answer Backfill

Completed daily answers that do not have a RAG document for the current embedding version are indexed one batch at a time. - Run repeatedly until Target becomes 0. Failed embeddings are saved and handled by the retry scheduler. + Set how many batches to run. Processing stops immediately when a batch fails or Target becomes 0. + Failed embeddings are saved and handled by the retry scheduler. + Status counts can be refreshed without running a batch.

@@ -228,10 +251,15 @@

Daily Answer Backfill

- +
+ + +
+ +
-
No run yet.
+
Loading status...
@@ -248,6 +276,8 @@ diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index 391d3dd8..db8502b5 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -243,6 +243,7 @@ .chart-card:nth-child(2) { animation-delay: .1s; } .chart-card:nth-child(3) { animation-delay: .15s; } + @@ -301,10 +302,13 @@
-
- 일간 가입자 수 +
+
+ 일간 가입자 수 +
+
최근 7일
-
최근 7일
+
@@ -314,10 +318,13 @@
-
- 할당된 질문 개수 +
+
+ 할당된 질문 개수 +
+
최근 7일 · 홈 화면 진입 시 할당
-
최근 7일 · 홈 화면 진입 시 할당
+
@@ -327,10 +334,13 @@
-
- 생성된 일간 리포트 개수 (DAU) +
+
+ 생성된 일간 리포트 개수 (DAU) +
+
최근 7일
-
최근 7일
+
diff --git a/src/main/resources/templates/stats/fragments/peak-record.html b/src/main/resources/templates/stats/fragments/peak-record.html new file mode 100644 index 00000000..6f59eb16 --- /dev/null +++ b/src/main/resources/templates/stats/fragments/peak-record.html @@ -0,0 +1,22 @@ + + + +
+
+ 역대 최고 + 현재 기간 +
+
+ 0 + 2026-08-13 +
+
기록 없음
+
+ + diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index 4a2aa44e..c7b473ea 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -173,6 +173,19 @@ line-height: 1; color: var(--text-1); } + .kpi-breakdown { + display: flex; + gap: 20px; + margin-top: 12px; + font-family: 'DM Mono', monospace; + font-size: .78rem; + color: var(--text-2); + } + .kpi-breakdown strong { + margin-left: 6px; + color: var(--text-1); + font-weight: 500; + } .charts-grid { display: grid; @@ -221,6 +234,7 @@ .dot-teal { background: var(--accent-3); } .dot-orange { background: #f5a524; } + @@ -267,16 +281,23 @@
-
현재 생성 중 월간 리포트
-
0
+
현재 생성 중 월간 리포트 · Total
+
0
+
+ monthly_reports0 + monthly_reports_v20 +
-
월간 가입자 수
-
최근 5개월
+
+
월간 가입자 수
+
최근 5개월
+
+
@@ -285,8 +306,11 @@
-
월간 할당 질문 수
-
최근 5개월
+
+
월간 할당 질문 수
+
최근 5개월
+
+
@@ -295,8 +319,11 @@
-
생성된 일간 리포트 수
-
최근 5개월
+
+
생성된 일간 리포트 수
+
최근 5개월
+
+
@@ -305,8 +332,11 @@
-
생성된 월간 리포트 수
-
최근 5개월
+
+
생성된 월간 리포트 수
+
최근 5개월
+
+
@@ -314,8 +344,11 @@
-
MAU
-
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
MAU
+
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
@@ -328,7 +361,9 @@ const signupCounts = [[${vm.signupCounts}]].map(v => parseInt(v, 10)); const assignedCounts = [[${vm.assignedQuestionCounts}]].map(v => parseInt(v, 10)); const completedDailyCounts = [[${vm.completedDailyReportCounts}]].map(v => parseInt(v, 10)); - const completedMonthlyCounts = [[${vm.completedMonthlyReportCounts}]].map(v => parseInt(v, 10)); + const completedMonthlyV1Counts = [[${vm.completedMonthlyReportV1Counts}]].map(v => parseInt(v, 10)); + const completedMonthlyV2Counts = [[${vm.completedMonthlyReportV2Counts}]].map(v => parseInt(v, 10)); + const completedMonthlyTotalCounts = [[${vm.completedMonthlyReportTotalCounts}]].map(v => parseInt(v, 10)); const mauCounts = [[${vm.mauCounts}]].map(v => parseInt(v, 10)); Chart.defaults.color = '#8b91a8'; @@ -346,40 +381,54 @@ } const configs = [ - { id: 'mauChart', label: 'MAU', data: mauCounts, color: 'rgba(108,143,255,1)' }, - { id: 'signupChart', label: '가입자 수', data: signupCounts, color: 'rgba(108,143,255,1)' }, - { id: 'assignedChart', label: '할당 질문 수', data: assignedCounts, color: 'rgba(255,126,179,1)' }, - { id: 'completedDailyChart', label: '일간 리포트 생성 수', data: completedDailyCounts, color: 'rgba(77,232,194,1)' }, - { id: 'completedMonthlyChart', label: '월간 리포트 생성 수', data: completedMonthlyCounts, color: 'rgba(245,165,36,1)' }, + { id: 'mauChart', datasets: [{ label: 'MAU', data: mauCounts, color: 'rgba(108,143,255,1)' }] }, + { id: 'signupChart', datasets: [{ label: '가입자 수', data: signupCounts, color: 'rgba(108,143,255,1)' }] }, + { id: 'assignedChart', datasets: [{ label: '할당 질문 수', data: assignedCounts, color: 'rgba(255,126,179,1)' }] }, + { id: 'completedDailyChart', datasets: [{ label: '일간 리포트 생성 수', data: completedDailyCounts, color: 'rgba(77,232,194,1)' }] }, + { + id: 'completedMonthlyChart', + showLegend: true, + datasets: [ + { label: 'monthly_reports', data: completedMonthlyV1Counts, color: 'rgba(139,145,168,1)', fill: false, borderDash: [6, 4] }, + { label: 'monthly_reports_v2', data: completedMonthlyV2Counts, color: 'rgba(168,135,255,1)', fill: false }, + { label: 'Total', data: completedMonthlyTotalCounts, color: 'rgba(245,165,36,1)', borderWidth: 3 }, + ] + }, ]; - configs.forEach(({ id, label, data, color }) => { + configs.forEach(({ id, datasets, showLegend = false }) => { const canvasEl = document.getElementById(id); const ctx2d = canvasEl.getContext('2d'); - const gradient = makeGradient(ctx2d, color); + const chartDatasets = datasets.map(({ + label, data, color, fill = true, borderWidth = 2, borderDash = [] + }) => ({ + label, + data, + tension: 0.35, + fill, + backgroundColor: fill ? makeGradient(ctx2d, color) : 'transparent', + borderColor: color, + borderWidth, + borderDash, + pointBackgroundColor: color, + pointRadius: 4, + pointHoverRadius: 6, + })); new Chart(canvasEl, { type: 'line', data: { labels, - datasets: [{ - label, - data, - tension: 0.35, - fill: true, - backgroundColor: gradient, - borderColor: color, - borderWidth: 2, - pointBackgroundColor: color, - pointRadius: 4, - pointHoverRadius: 6, - }] + datasets: chartDatasets }, options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { - legend: { display: false }, + legend: { + display: showLegend, + labels: { usePointStyle: true, pointStyle: 'line' }, + }, tooltip: { backgroundColor: '#1e2230', borderColor: 'rgba(255,255,255,0.1)', diff --git a/src/main/resources/templates/stats/weekly.html b/src/main/resources/templates/stats/weekly.html index 7ebc1192..8554e08e 100644 --- a/src/main/resources/templates/stats/weekly.html +++ b/src/main/resources/templates/stats/weekly.html @@ -221,6 +221,7 @@ .dot-teal { background: var(--accent-3); } .dot-orange { background: #f5a524; } + @@ -275,8 +276,11 @@
-
주간 가입자 수
-
최근 5주
+
+
주간 가입자 수
+
최근 5주
+
+
@@ -285,8 +289,11 @@
-
주간 할당 질문 수
-
최근 5주
+
+
주간 할당 질문 수
+
최근 5주
+
+
@@ -295,8 +302,11 @@
-
생성된 일간 리포트 수
-
최근 5주
+
+
생성된 일간 리포트 수
+
최근 5주
+
+
@@ -305,8 +315,11 @@
-
생성된 주간 리포트 수
-
최근 5주
+
+
생성된 주간 리포트 수
+
최근 5주
+
+
@@ -314,8 +327,11 @@
-
WAU
-
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
WAU
+
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagControllerTest.java new file mode 100644 index 00000000..2e76cc15 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagControllerTest.java @@ -0,0 +1,39 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatRagBackfillStatusResponse; +import com.devkor.ifive.nadab.domain.askchat.application.AskChatRagBackfillService; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAskChatRagControllerTest { + + @Mock + private AskChatRagBackfillService askChatRagBackfillService; + + @InjectMocks + private AdminAskChatRagController controller; + + @Test + void getDailyAnswerBackfillStatus_returns_current_counts() { + when(askChatRagBackfillService.getCompletedDailyAnswerStatus()) + .thenReturn(new AskChatRagBackfillStatusDto(4, 12, 2)); + + ResponseEntity> response = + controller.getDailyAnswerBackfillStatus(); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()) + .isEqualTo(new AdminAskChatRagBackfillStatusResponse(4, 12, 2)); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java index e8afc9dd..1804c58c 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionControllerTest.java @@ -6,6 +6,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatHomeResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatMessageResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSessionResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatTurnChargeResponse; @@ -98,6 +99,7 @@ void startSession_delegates_first_question_to_session_service() { ErrorCode.AI_RESPONSE_PARSE_FAILED, "generation failed" ), + 9, List.of() ); when(askChatSessionService.startSession(1L, "나는 어떤 사람이야?")).thenReturn(sendResponse); @@ -138,6 +140,7 @@ void sendQuestion_delegates_user_session_and_content_to_message_service() { ErrorCode.AI_RESPONSE_PARSE_FAILED, "generation failed" ), + 9, List.of() ); when(askChatMessageCommandService.sendQuestion(1L, 10L, "question")).thenReturn(sendResponse); @@ -174,6 +177,23 @@ void chargeTurns_delegates_to_wallet_charge_service() { verify(askChatWalletChargeService).chargeTurns(1L); } + @Test + void getRemainingTurns_returns_remaining_message_count_only() { + AskChatSessionController controller = controller(); + UserPrincipal principal = new UserPrincipal(1L); + AskChatRemainingMessageCountResponse remainingResponse = + new AskChatRemainingMessageCountResponse(9); + when(askChatSessionService.getRemainingMessageCount(1L)).thenReturn(remainingResponse); + + ResponseEntity> response = + controller.getRemainingTurns(principal); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().remainingMessageCount()).isEqualTo(9); + verify(askChatSessionService).getRemainingMessageCount(1L); + } + private AskChatSessionController controller() { return new AskChatSessionController( askChatSessionService, diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java index 9c1ad521..332a66c9 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatMessageCommandServiceTest.java @@ -11,12 +11,15 @@ import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatRagDocument; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSessionStatus; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; import com.devkor.ifive.nadab.domain.askchat.core.properties.AskChatAnswerProperties; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatMessageReferenceRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatMessageRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagDocumentRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSessionRepository; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatAnswerLlmClient; +import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.ConflictException; @@ -75,6 +78,9 @@ class AskChatMessageCommandServiceTest { @Mock private AskChatTurnReservationService askChatTurnReservationService; + @Mock + private AskChatWalletRepository askChatWalletRepository; + private AskChatMessageCommandService service; private AskChatAnswerProperties askChatAnswerProperties; @@ -91,7 +97,8 @@ void setUp() { askChatAnswerContextService, askChatAnswerLlmClient, askChatAnswerProperties, - askChatTurnReservationService + askChatTurnReservationService, + askChatWalletRepository ); } @@ -127,11 +134,13 @@ void sendQuestion_saves_user_message_to_requested_session() { eq(AskChatSessionService.MAX_TURN_COUNT), any(OffsetDateTime.class) )).thenReturn(1); + when(askChatWalletRepository.findByUserId(1L)).thenReturn(Optional.of(wallet(3, 5))); var response = service.sendQuestion(1L, 10L, " 나는 어떤 사람이야? "); assertThat(response.session().sessionId()).isEqualTo(10L); assertThat(response.session().answeredTurnCount()).isEqualTo(3); + assertThat(response.remainingMessageCount()).isEqualTo(8); assertThat(response.userMessage().role()).isEqualTo(AskChatMessageRole.USER); assertThat(response.userMessage().status()).isEqualTo(AskChatMessageStatus.COMPLETED); assertThat(response.userMessage().content()).isEqualTo("나는 어떤 사람이야?"); @@ -205,11 +214,13 @@ void sendQuestion_saves_failed_assistant_message_when_answer_generation_fails() when(askChatTurnReservationService.reserveTurn(1L, activeSession)).thenReturn(reservation); when(askChatAnswerLlmClient.generate(context)) .thenThrow(new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED)); + when(askChatWalletRepository.findByUserId(1L)).thenReturn(Optional.of(wallet(2, 7))); var response = service.sendQuestion(1L, 11L, "나는 어떤 사람이야?"); assertThat(response.userMessage().status()).isEqualTo(AskChatMessageStatus.COMPLETED); assertThat(response.assistantMessage()).isNull(); + assertThat(response.remainingMessageCount()).isEqualTo(9); assertThat(response.answerGeneration().success()).isFalse(); assertThat(response.answerGeneration().errorCode()).isEqualTo(ErrorCode.AI_RESPONSE_PARSE_FAILED.getCode()); assertThat(response.answerGeneration().message()).isEqualTo("답변 생성에 오류가 발생했어요. 다시 시도해주세요."); @@ -331,6 +342,10 @@ private AskChatTurnReservation reservation() { return new AskChatTurnReservation(1000L, -1, 0); } + private AskChatWallet wallet(int freeTurnBalance, int paidTurnBalance) { + return AskChatWallet.create(mock(User.class), freeTurnBalance, paidTurnBalance); + } + private AskChatSession session( Long id, AskChatSessionStatus status, diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java index 6e87b0f7..9aaab9ac 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.askchat.application; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillResultDto; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagBackfillQueryRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatEmbeddingClient; @@ -31,6 +32,22 @@ class AskChatRagBackfillServiceTest { @Mock private AskChatEmbeddingClient embeddingClient; + @Test + void getCompletedDailyAnswerStatus_queries_current_embedding_version() { + AskChatRagBackfillService service = new AskChatRagBackfillService( + backfillQueryRepository, + indexingService, + embeddingClient + ); + AskChatRagBackfillStatusDto expectedStatus = new AskChatRagBackfillStatusDto(3, 7, 1); + when(embeddingClient.version()).thenReturn(2); + when(backfillQueryRepository.findCompletedDailyAnswerStatus(2)).thenReturn(expectedStatus); + + AskChatRagBackfillStatusDto status = service.getCompletedDailyAnswerStatus(); + + assertThat(status).isEqualTo(expectedStatus); + } + @Test void backfillCompletedDailyAnswers_indexes_targets_for_current_embedding_version() { AskChatRagBackfillService service = new AskChatRagBackfillService( diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java index 8d227271..0bad4036 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java @@ -146,6 +146,22 @@ void startSession_creates_new_session_and_sends_first_question() { verifyNoInteractions(answerEntryRepository); } + @Test + void getRemainingMessageCount_returns_total_turn_balance() { + User user = mock(User.class); + when(askChatWalletRepository.findByUserId(1L)) + .thenReturn(Optional.of(AskChatWallet.create(user, 2, 7))); + + var response = service.getRemainingMessageCount(1L); + + assertThat(response.remainingMessageCount()).isEqualTo(9); + verifyNoInteractions(answerEntryRepository); + verifyNoInteractions(userRepository); + verifyNoInteractions(userWalletRepository); + verifyNoInteractions(askChatSampleQuestionRepository); + verify(askChatSessionRepository, never()).save(any()); + } + @Test void startSession_rejects_missing_user_when_creating_session() { when(userRepository.findById(1L)).thenReturn(Optional.empty()); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java index 61c9cbfa..2c8ece45 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.askchat.core.repository; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagSearchResultDto; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; @@ -68,6 +69,33 @@ void findCompletedDailyAnswerTargets_excludes_already_indexed_sources_and_limits .containsExactly(secondReportId, thirdReportId); } + @Test + void findCompletedDailyAnswerStatus_counts_targets_indexed_and_failed_for_current_version() { + Long userId = insertUser("backfill-status"); + Long questionId = insertDailyQuestion(InterestCode.RELATIONSHIP, "status question"); + Long targetAnswerId = insertAnswerEntry(userId, questionId, "target", LocalDate.of(2026, 7, 10)); + Long indexedAnswerId = insertAnswerEntry(userId, questionId, "indexed", LocalDate.of(2026, 7, 11)); + Long failedAnswerId = insertAnswerEntry(userId, questionId, "failed", LocalDate.of(2026, 7, 12)); + Long deadLetterAnswerId = insertAnswerEntry(userId, questionId, "dead letter", LocalDate.of(2026, 7, 13)); + Long oldVersionAnswerId = insertAnswerEntry(userId, questionId, "old version", LocalDate.of(2026, 7, 14)); + insertDailyReport(targetAnswerId, "target report", "COMPLETED", LocalDate.of(2026, 7, 10)); + insertDailyReport(indexedAnswerId, "indexed report", "COMPLETED", LocalDate.of(2026, 7, 11)); + insertDailyReport(failedAnswerId, "failed report", "COMPLETED", LocalDate.of(2026, 7, 12)); + insertDailyReport(deadLetterAnswerId, "dead letter report", "COMPLETED", LocalDate.of(2026, 7, 13)); + insertDailyReport(oldVersionAnswerId, "old version report", "COMPLETED", LocalDate.of(2026, 7, 14)); + insertRagDocument(userId, "ANSWER_ENTRY", indexedAnswerId, InterestCode.RELATIONSHIP, 2, "COMPLETED"); + insertRagDocument(userId, "ANSWER_ENTRY", failedAnswerId, InterestCode.RELATIONSHIP, 2, "FAILED"); + insertRagDocument(userId, "ANSWER_ENTRY", deadLetterAnswerId, InterestCode.RELATIONSHIP, 2, "DEAD_LETTER"); + insertRagDocument(userId, "ANSWER_ENTRY", oldVersionAnswerId, InterestCode.RELATIONSHIP, 1, "COMPLETED"); + + AskChatRagBackfillStatusDto status = + backfillQueryRepository.findCompletedDailyAnswerStatus(2); + + assertThat(status.targetCount()).isEqualTo(2); + assertThat(status.indexedCount()).isEqualTo(1); + assertThat(status.failedCount()).isEqualTo(2); + } + @Test void updateEmbedding_and_markEmbeddingFailed_update_pgvector_document_status() { Long userId = insertUser("vector-update"); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClientTest.java index 271f2982..c687e180 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClientTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/infra/AskChatAnswerLlmClientTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.client.ChatClient; @@ -25,12 +26,14 @@ import org.springframework.ai.chat.metadata.DefaultUsage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.openai.OpenAiChatOptions; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -59,8 +62,8 @@ class AskChatAnswerLlmClientTest { void setUp() { properties = new AskChatAnswerProperties(); properties.setProvider(LlmProvider.OPENAI); - properties.setModel("gpt-4o-mini"); - properties.setTemperature(0.3); + properties.setModel("gpt-5.6-luna"); + properties.setTemperature(1.0); properties.setMaxTokens(900); properties.setFollowUpQuestionCount(2); objectMapper = new ObjectMapper(); @@ -87,14 +90,20 @@ void generate_returns_answer_with_token_usage_and_reference_document_ids() throw var result = client.generate(context); + ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(OpenAiChatOptions.class); + verify(requestSpec).options(optionsCaptor.capture()); + assertThat(result.answer().answer()).isEqualTo("꾸준함이 강점으로 보여요."); assertThat(result.answer().followUpQuestions()).hasSize(2); assertThat(result.provider()).isEqualTo(LlmProvider.OPENAI); - assertThat(result.model()).isEqualTo("gpt-4o-mini"); + assertThat(result.model()).isEqualTo("gpt-5.6-luna"); assertThat(result.tokenUsage().inputTokens()).isEqualTo(100L); assertThat(result.tokenUsage().outputTokens()).isEqualTo(50L); assertThat(result.tokenUsage().totalTokens()).isEqualTo(150L); assertThat(result.referenceDocumentIds()).containsExactly(100L); + assertThat(optionsCaptor.getValue().getTemperature()).isEqualTo(1.0); + assertThat(optionsCaptor.getValue().getMaxTokens()).isNull(); + assertThat(optionsCaptor.getValue().getMaxCompletionTokens()).isEqualTo(900); } @Test diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenServiceTest.java new file mode 100644 index 00000000..2e34220d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenServiceTest.java @@ -0,0 +1,51 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.api.dto.response.LocalTokenResponse; +import com.devkor.ifive.nadab.domain.auth.infra.LocalDummyUserRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.security.token.AccessTokenProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LocalTokenServiceTest { + + @Mock + private AccessTokenProvider accessTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private LocalDummyUserRepository localDummyUserRepository; + + @InjectMocks + private LocalTokenService localTokenService; + + @Test + void issueDummyAccessToken_creates_dummy_user_if_absent_and_returns_access_token() { + User dummyUser = User.createUser("test@example.com", "hashed_pw"); + ReflectionTestUtils.setField(dummyUser, "id", 11111L); + when(localDummyUserRepository.createIfAbsent()).thenReturn(11111L); + when(userRepository.findById(11111L)).thenReturn(Optional.of(dummyUser)); + when(accessTokenProvider.generateToken(11111L, List.of("USER"))).thenReturn("access-token"); + + LocalTokenResponse response = localTokenService.issueDummyAccessToken(); + + assertThat(response.accessToken()).isEqualTo("access-token"); + verify(localDummyUserRepository).createIfAbsent(); + verify(accessTokenProvider).generateToken(11111L, List.of("USER")); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepositoryTest.java new file mode 100644 index 00000000..5bb8b0a9 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepositoryTest.java @@ -0,0 +1,103 @@ +package com.devkor.ifive.nadab.domain.auth.infra; + +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles({"test", "local"}) +@Import(LocalDummyUserRepository.class) +class LocalDummyUserRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + private LocalDummyUserRepository localDummyUserRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void createIfAbsent_creates_dummy_user_interest_and_wallets() { + Long userId = localDummyUserRepository.createIfAbsent(); + + assertThat(userId).isEqualTo(11111L); + assertThat(queryString("SELECT email FROM users WHERE id = 11111")).isEqualTo("test@example.com"); + assertThat(queryString("SELECT password_hash FROM users WHERE id = 11111")).isEqualTo("hashed_pw"); + assertThat(queryString("SELECT nickname FROM users WHERE id = 11111")).isEqualTo("TestUser"); + assertThat(queryString("SELECT default_profile_type FROM users WHERE id = 11111")).isEqualTo("DEFAULT"); + assertThat(queryString("SELECT signup_status FROM users WHERE id = 11111")).isEqualTo("PROFILE_INCOMPLETE"); + assertThat(queryString(""" + SELECT i.code + FROM user_interests ui + JOIN interests i ON i.id = ui.interest_id + WHERE ui.user_id = 11111 + """)).isEqualTo("ROUTINE"); + assertThat(queryLong( + "SELECT crystal_balance FROM user_wallets WHERE user_id = 11111" + )).isEqualTo(1_000L); + assertThat(queryInteger( + "SELECT free_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(3); + assertThat(queryInteger( + "SELECT paid_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(1_000); + assertThat(queryLong( + "SELECT version FROM ask_chat_wallets WHERE user_id = 11111" + )).isZero(); + } + + @Test + void createIfAbsent_does_not_reset_existing_wallet_balances() { + localDummyUserRepository.createIfAbsent(); + jdbcTemplate.update( + "UPDATE user_wallets SET crystal_balance = 900 WHERE user_id = 11111" + ); + jdbcTemplate.update(""" + UPDATE ask_chat_wallets + SET free_turn_balance = 2, + paid_turn_balance = 900 + WHERE user_id = 11111 + """); + + localDummyUserRepository.createIfAbsent(); + + assertThat(queryLong( + "SELECT COUNT(*) FROM users WHERE id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT COUNT(*) FROM user_interests WHERE user_id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT COUNT(*) FROM user_wallets WHERE user_id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT COUNT(*) FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT crystal_balance FROM user_wallets WHERE user_id = 11111" + )).isEqualTo(900L); + assertThat(queryInteger( + "SELECT free_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(2); + assertThat(queryInteger( + "SELECT paid_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(900); + } + + private String queryString(String sql) { + return jdbcTemplate.queryForObject(sql, String.class); + } + + private Long queryLong(String sql) { + return jdbcTemplate.queryForObject(sql, Long.class); + } + + private Integer queryInteger(String sql) { + return jdbcTemplate.queryForObject(sql, Integer.class); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java index 22e57af5..909e9ddd 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java @@ -106,13 +106,14 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log( .thenReturn(Optional.of(UserDailyQuestion.create(user, today, question))); when(dailyReportTxService.prepareDaily(user, question, "answer", false, null)) .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId)); + when(dailyReportLlmClient.model()).thenReturn("gpt-5.6-luna"); when(reportGenerationLogRecorder.start( userId, ReportGenerationType.DAILY, reportId, ReportGenerationStep.DAILY_GENERATE, LlmProvider.OPENAI, - "GPT_4_O_MINI" + "gpt-5.6-luna" )).thenReturn(generationLogId); when(dailyReportLlmClient.generate("question", answerEntry)) .thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L))); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java index 8723f771..f5ffd78d 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java @@ -1,6 +1,8 @@ package com.devkor.ifive.nadab.domain.dailyreport.infra; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties; +import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties.TokenLimitParameter; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; @@ -11,6 +13,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.client.ChatClient; @@ -20,6 +23,7 @@ import org.springframework.ai.chat.metadata.DefaultUsage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.openai.OpenAiChatOptions; import java.time.LocalDate; import java.util.List; @@ -27,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -51,13 +56,56 @@ class DailyReportLlmClientTest { ChatClient.CallResponseSpec callResponseSpec; @Test - void generate_returns_content_with_token_usage() { + void generate_uses_default_options_and_returns_content_with_token_usage() { // given + DailyReportLlmProperties properties = new DailyReportLlmProperties(); + + // when + GenerationExecution execution = generateWith(properties); + + // then + assertThat(execution.result().content()).isEqualTo(new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto( + "good", + "ACHIEVEMENT" + )); + assertThat(execution.result().tokenUsage().inputTokens()).isEqualTo(100L); + assertThat(execution.result().tokenUsage().outputTokens()).isEqualTo(50L); + assertThat(execution.result().tokenUsage().totalTokens()).isEqualTo(150L); + assertThat(execution.options().getModel()).isEqualTo("gpt-4o-mini"); + assertThat(execution.options().getTemperature()).isEqualTo(0.3); + assertThat(execution.options().getMaxTokens()).isEqualTo(512); + assertThat(execution.options().getMaxCompletionTokens()).isNull(); + assertThat(execution.options().getReasoningEffort()).isNull(); + } + + @Test + void generate_uses_dev_experiment_options() { + // given + DailyReportLlmProperties properties = new DailyReportLlmProperties(); + properties.setModel("gpt-5.6-luna"); + properties.setTemperature(1.0); + properties.setMaxOutputTokens(512); + properties.setTokenLimitParameter(TokenLimitParameter.MAX_COMPLETION_TOKENS); + properties.setReasoningEffort("none"); + + // when + GenerationExecution execution = generateWith(properties); + + // then + assertThat(execution.options().getModel()).isEqualTo("gpt-5.6-luna"); + assertThat(execution.options().getTemperature()).isEqualTo(1.0); + assertThat(execution.options().getMaxTokens()).isNull(); + assertThat(execution.options().getMaxCompletionTokens()).isEqualTo(512); + assertThat(execution.options().getReasoningEffort()).isEqualTo("none"); + } + + private GenerationExecution generateWith(DailyReportLlmProperties properties) { DailyReportLlmClient client = new DailyReportLlmClient( dailyReportPromptLoader, new ObjectMapper(), llmRouter, - profileImageUrlBuilder + profileImageUrlBuilder, + properties ); AnswerEntry answerEntry = AnswerEntry.create( User.createUser("test@test.com", "hashed_password"), @@ -79,17 +127,12 @@ void generate_returns_content_with_token_usage() { new DefaultUsage(100, 50, 150) )); - // when LlmGenerationResult result = client.generate("question", answerEntry); - // then - assertThat(result.content()).isEqualTo(new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto( - "good", - "ACHIEVEMENT" - )); - assertThat(result.tokenUsage().inputTokens()).isEqualTo(100L); - assertThat(result.tokenUsage().outputTokens()).isEqualTo(50L); - assertThat(result.tokenUsage().totalTokens()).isEqualTo(150L); + ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(OpenAiChatOptions.class); + verify(requestSpec).options(optionsCaptor.capture()); + + return new GenerationExecution(result, optionsCaptor.getValue()); } private ChatResponse chatResponse(String content, DefaultUsage usage) { @@ -104,4 +147,7 @@ private ChatResponse chatResponse(String content, DefaultUsage usage) { private DailyQuestion dailyQuestion(Long id) { return mock(DailyQuestion.class); } + + private record GenerationExecution(LlmGenerationResult result, OpenAiChatOptions options) { + } } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java index c159a521..58e4abaa 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/PdfExportRollbackBoundaryTest.java @@ -201,6 +201,33 @@ void tearDown() throws IOException { verify(storage, never()).delete(anyString()); } + /* ── 실패 알림 발행 여부를 가르는 반환값 ──────────────────────────── */ + + @Test + void 실제로_환불하면_참을_돌려주고_지갑과_상태를_되돌린다() { + Reserved reserved = inProgressJob(fundedUser); + + boolean refunded = txService.failAndRefund(fundedUser.getId(), reserved.jobId(), reserved.logId(), + ErrorCode.PDF_EXPORT_GENERATION_FAILED.name()); + + assertThat(refunded).isTrue(); + assertThat(statusOf(reserved.jobId())).isEqualTo(PdfExportStatus.FAILED); + assertThat(logStatusOf(reserved.logId())).isEqualTo(CrystalLogStatus.REFUNDED); + assertThat(balanceOf(fundedUser)).isEqualTo(FUNDED_BALANCE); // 차감분 원복 + } + + @Test + void 이미_완료된_작업엔_거짓을_돌려준다() { + Reserved reserved = inProgressJob(fundedUser); + txService.confirm(reserved.jobId(), reserved.logId()); + + // markFailed 경합에서 진 호출(복구 스윕이 뒤늦게 도는 경우)이라 false. + // true 면 이미 완성된 PDF 에 "생성에 실패했어요" 푸시가 나가므로, 호출자는 이 값으로 알림을 막는다. + assertThat(txService.failAndRefund(fundedUser.getId(), reserved.jobId(), reserved.logId(), + ErrorCode.PDF_EXPORT_GENERATION_TIMEOUT.name())) + .isFalse(); + } + /* ── 픽스처·조회 ─────────────────────────────────────────────────── */ private static final Long UNKNOWN_USER_ID = -1L; diff --git a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java index b4dd3ff9..cafa3780 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/listener/PdfExportGenerationListenerTest.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportCompletedEvent; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.application.helper.PdfExportRenderQueue; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfHtmlAssembler; import com.devkor.ifive.nadab.domain.pdfexport.application.render.PdfRenderer; @@ -96,6 +97,7 @@ void setUp() throws IOException { when(assembler.assemble(any(), any(), any(), any(), any(), any())) .thenReturn(new PdfHtmlAssembler.AssembledDocument(XHTML, Map.of())); when(renderer.render(any(), any())).thenReturn(pdfFile); + when(txService.confirm(anyLong(), anyLong())).thenReturn(true); listener = new PdfExportGenerationListener( jobRepository, queryRepository, assembler, renderer, storage, txService, renderQueue, eventPublisher); @@ -185,6 +187,20 @@ void tearDown() throws IOException { verify(txService, never()).failAndRefund(anyLong(), anyLong(), anyLong(), any()); } + @Test + void 복구_스윕이_먼저_실패처리했으면_완료를_알리지_않는다() { + givenJob(PdfExportType.ANSWER_ONLY); + when(queryRepository.findAnswersInPeriod(USER_ID, START, END)).thenReturn(List.of()); + // confirm 이 markCompleted 경합에서 짐 = 복구 스윕이 먼저 FAILED·환불 처리했다. + when(txService.confirm(anyLong(), anyLong())).thenReturn(false); + + listener.handle(EVENT); + + // 업로드는 됐지만 확정에 실패했으므로 완성 푸시를 보내면 안 된다(눌러도 아카이브에 없고 크리스탈은 환불됨). + verify(storage).upload(any(), any(), any(), any()); + verifyNoInteractions(eventPublisher); + } + @Test void 리포트답변_유형은_답변과_리포트를_모두_조회한다() { givenJob(PdfExportType.REPORT_AND_ANSWER); @@ -208,6 +224,7 @@ void tearDown() throws IOException { when(queryRepository.findAnswersInPeriod(USER_ID, START, END)) .thenReturn(List.of(answer("2026-01-05", "질문", null))); when(renderer.render(any(), any())).thenThrow(new RuntimeException("openhtmltopdf boom")); + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), any())).thenReturn(true); listener.handle(EVENT); @@ -216,6 +233,26 @@ void tearDown() throws IOException { verify(txService).failAndRefund(USER_ID, JOB_ID, CRYSTAL_LOG_ID, "PDF_EXPORT_GENERATION_FAILED"); verify(txService, never()).confirm(anyLong(), anyLong()); verify(storage, never()).upload(any(), any(), any(), any()); + + // 생성 화면을 벗어난 사용자는 이 알림이 없으면 실패를 알 방법이 없다(아카이브·/current 모두 FAILED 미노출). + ArgumentCaptor failed = ArgumentCaptor.forClass(PdfExportFailedEvent.class); + verify(eventPublisher).publishEvent(failed.capture()); + assertThat(failed.getValue().getJobId()).isEqualTo(JOB_ID); + assertThat(failed.getValue().getUserId()).isEqualTo(USER_ID); + } + + @Test + void 복구_스윕이_먼저_환불했으면_실패_알림을_보내지_않는다() { + givenJob(PdfExportType.ANSWER_ONLY); + when(queryRepository.findAnswersInPeriod(USER_ID, START, END)) + .thenReturn(List.of(answer("2026-01-05", "질문", null))); + when(renderer.render(any(), any())).thenThrow(new RuntimeException("openhtmltopdf boom")); + // false = markFailed 경합에서 짐. 스윕이 이미 실패 확정·환불했고 알림도 그쪽이 보냈다. + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), any())).thenReturn(false); + + listener.handle(EVENT); + + // 여기서 또 발행하면 같은 작업에 실패 푸시가 두 번 간다. verifyNoInteractions(eventPublisher); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java index 966e04f0..18e76435 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/pdfexport/application/scheduler/PdfExportRecoverySchedulerTest.java @@ -1,14 +1,18 @@ package com.devkor.ifive.nadab.domain.pdfexport.application.scheduler; import com.devkor.ifive.nadab.domain.pdfexport.application.PdfExportTxService; +import com.devkor.ifive.nadab.domain.pdfexport.application.event.PdfExportFailedEvent; import com.devkor.ifive.nadab.domain.pdfexport.core.entity.PdfExportJob; import com.devkor.ifive.nadab.domain.pdfexport.core.repository.PdfExportJobRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; @@ -28,13 +32,15 @@ class PdfExportRecoverySchedulerTest { private PdfExportJobRepository jobRepository; private PdfExportTxService txService; + private ApplicationEventPublisher eventPublisher; private PdfExportRecoveryScheduler scheduler; @BeforeEach void setUp() { jobRepository = mock(PdfExportJobRepository.class); txService = mock(PdfExportTxService.class); - scheduler = new PdfExportRecoveryScheduler(jobRepository, txService); + eventPublisher = mock(ApplicationEventPublisher.class); + scheduler = new PdfExportRecoveryScheduler(jobRepository, txService, eventPublisher); } @Test @@ -43,6 +49,7 @@ void setUp() { List stuck = List.of(stuckJob(11L, 7L, 101L), stuckJob(12L, 8L, 102L), stuckJob(13L, 9L, 103L)); when(jobRepository.findStuckInProgress(any(), anyInt())).thenReturn(stuck); + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(true); doThrow(new IllegalStateException("wallet down")) .when(txService).failAndRefund(eq(8L), anyLong(), anyLong(), anyString()); @@ -53,6 +60,23 @@ void setUp() { verify(txService).failAndRefund(9L, 13L, 103L, "PDF_EXPORT_GENERATION_TIMEOUT"); } + @Test + void 환불한_건만_실패_알림을_발행한다() { + List stuck = List.of(stuckJob(11L, 7L, 101L), stuckJob(12L, 8L, 102L)); + when(jobRepository.findStuckInProgress(any(), anyInt())).thenReturn(stuck); + when(txService.failAndRefund(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(true); + // 8번 유저 건은 그 사이 렌더 리스너가 먼저 정리해 CAS 경합에서 졌다. + when(txService.failAndRefund(eq(8L), anyLong(), anyLong(), anyString())).thenReturn(false); + + scheduler.recoverStuckJobs(); + + // 환불에 성공한 11번만 발행되고, CAS 경합에서 진 8번은 발행되지 않는다. + ArgumentCaptor failed = ArgumentCaptor.forClass(PdfExportFailedEvent.class); + verify(eventPublisher).publishEvent(failed.capture()); + assertThat(failed.getValue().getJobId()).isEqualTo(11L); + assertThat(failed.getValue().getUserId()).isEqualTo(7L); + } + /** 조회 결과 대역 — 스윕이 읽는 건 id·userId·crystalLogId 뿐이다. */ private PdfExportJob stuckJob(long jobId, long userId, long crystalLogId) { User user = mock(User.class); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsServiceTest.java new file mode 100644 index 00000000..09f7b077 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsServiceTest.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.repository.DailyStatsRepository; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import org.junit.jupiter.api.Test; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DailyStatsServiceTest { + + @Test + void getDailyStats_updates_daily_metric_peaks_from_chart_data() { + DailyStatsRepository repo = mock(DailyStatsRepository.class); + DailyStatsService service = new DailyStatsService( + repo, + new PeakStatsTracker(new InMemoryPeakStatsStore()) + ); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + LocalDate twoDaysAgo = today.minusDays(2); + + when(repo.findSignupCountsLast7Days(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new Object[]{Date.valueOf(twoDaysAgo), 5L})); + when(repo.findAssignedQuestionCountsLast7Days(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(today.minusDays(1), 7L))); + when(repo.findCompletedDailyReportCountsLast7Days(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(today, 9L))); + + DailyStatsViewModel vm = service.getDailyStatsLast7Days(); + + assertThat(vm.signupPeak().value()).isEqualTo(5L); + assertThat(vm.signupPeak().periodStart()).isEqualTo(twoDaysAgo.toString()); + assertThat(vm.assignedQuestionPeak().value()).isEqualTo(7L); + assertThat(vm.dauPeak().value()).isEqualTo(9L); + assertThat(vm.dauPeak().currentPeriod()).isTrue(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java new file mode 100644 index 00000000..98a67b52 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java @@ -0,0 +1,70 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MonthlyStatsServiceTest { + + @Test + void getMonthlyStats_combines_v1_and_v2_monthly_report_counts() { + // given + MonthlyStatsRepository repo = mock(MonthlyStatsRepository.class); + MonthlyStatsService service = new MonthlyStatsService( + repo, + new PeakStatsTracker(new InMemoryPeakStatsStore()) + ); + + YearMonth currentMonth = YearMonth.from(TodayDateTimeProvider.getTodayDate()); + YearMonth previousMonth = currentMonth.minusMonths(1); + + when(repo.findSignupCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findAssignedQuestionCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findCompletedDailyReportCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findMonthlyActiveUserCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findCompletedMonthlyReportV1CountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of( + new DateCountDto(previousMonth.atDay(15), 2L), + new DateCountDto(currentMonth.atDay(1), 3L) + )); + when(repo.findCompletedMonthlyReportV2CountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of( + new DateCountDto(previousMonth.atDay(20), 5L), + new DateCountDto(currentMonth.atDay(2), 7L) + )); + when(repo.countInProgressMonthlyReportV1Now()).thenReturn(1L); + when(repo.countInProgressMonthlyReportV2Now()).thenReturn(4L); + + // when + MonthlyStatsViewModel vm = service.getMonthlyStatsLast5Months(); + + // then + assertThat(vm.completedMonthlyReportV1Counts()).containsExactly(0L, 0L, 0L, 2L, 3L); + assertThat(vm.completedMonthlyReportV2Counts()).containsExactly(0L, 0L, 0L, 5L, 7L); + assertThat(vm.completedMonthlyReportTotalCounts()).containsExactly(0L, 0L, 0L, 7L, 10L); + assertThat(vm.completedMonthlyReportPeak().value()).isEqualTo(10L); + assertThat(vm.completedMonthlyReportPeak().periodLabel()).isEqualTo(currentMonth.toString()); + assertThat(vm.completedMonthlyReportPeak().currentPeriod()).isTrue(); + assertThat(vm.signupPeak().available()).isFalse(); + assertThat(vm.inProgressMonthlyReportV1Count()).isEqualTo(1L); + assertThat(vm.inProgressMonthlyReportV2Count()).isEqualTo(4L); + assertThat(vm.inProgressMonthlyReportTotalCount()).isEqualTo(5L); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializerTest.java new file mode 100644 index 00000000..962a72fb --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializerTest.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsRepository; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PeakStatsInitializerTest { + + private final PeakStatsRepository repository = mock(PeakStatsRepository.class); + private final PeakStatsStore store = mock(PeakStatsStore.class); + private final PeakStatsInitializer initializer = new PeakStatsInitializer(repository, store); + + @Test + void run_initializes_all_aggregated_peak_statistics() { + PeakStat dau = new PeakStat(10L, LocalDate.of(2026, 8, 1)); + PeakStat wau = new PeakStat(20L, LocalDate.of(2026, 7, 27)); + when(repository.findAllPeakStats()).thenReturn(Map.of( + PeakMetric.DAU, dau, + PeakMetric.WAU, wau + )); + + initializer.run(null); + + verify(store).updateIfGreater(PeakMetric.DAU, dau); + verify(store).updateIfGreater(PeakMetric.WAU, wau); + } + + @Test + void run_does_not_fail_application_when_initial_aggregation_fails() { + when(repository.findAllPeakStats()).thenThrow(new IllegalStateException("database unavailable")); + + assertThatCode(() -> initializer.run(null)).doesNotThrowAnyException(); + + verifyNoInteractions(store); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsServiceTest.java new file mode 100644 index 00000000..a441f3b8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsServiceTest.java @@ -0,0 +1,58 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import org.junit.jupiter.api.Test; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WeeklyStatsServiceTest { + + @Test + void getWeeklyStats_updates_weekly_metric_peaks_from_aggregated_chart_data() { + WeeklyStatsRepository repo = mock(WeeklyStatsRepository.class); + WeeklyStatsService service = new WeeklyStatsService( + repo, + new PeakStatsTracker(new InMemoryPeakStatsStore()) + ); + LocalDate currentWeekStart = TodayDateTimeProvider.getTodayDate() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate previousWeekStart = currentWeekStart.minusWeeks(1); + + when(repo.findSignupCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(previousWeekStart.plusDays(1), 4L))); + when(repo.findAssignedQuestionCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(currentWeekStart.plusDays(1), 6L))); + when(repo.findCompletedDailyReportCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of( + new DateCountDto(previousWeekStart, 5L), + new DateCountDto(previousWeekStart.plusDays(1), 7L) + )); + when(repo.findCompletedWeeklyReportCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(currentWeekStart, 3L))); + when(repo.findWeeklyActiveUserCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(currentWeekStart, 8L))); + + WeeklyStatsViewModel vm = service.getWeeklyStatsLast7Weeks(); + + assertThat(vm.signupPeak().value()).isEqualTo(4L); + assertThat(vm.assignedQuestionPeak().value()).isEqualTo(6L); + assertThat(vm.completedDailyReportPeak().value()).isEqualTo(12L); + assertThat(vm.completedDailyReportPeak().periodStart()).isEqualTo(previousWeekStart.toString()); + assertThat(vm.completedWeeklyReportPeak().value()).isEqualTo(3L); + assertThat(vm.wauPeak().value()).isEqualTo(8L); + assertThat(vm.wauPeak().currentPeriod()).isTrue(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTrackerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTrackerTest.java new file mode 100644 index 00000000..28416e96 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTrackerTest.java @@ -0,0 +1,96 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PeakStatsTrackerTest { + + private final InMemoryPeakStatsStore store = new InMemoryPeakStatsStore(); + private final PeakStatsTracker tracker = new PeakStatsTracker(store); + + @Test + void updateAndGet_updates_peak_with_latest_period_when_values_are_equal() { + LocalDate currentPeriodStart = LocalDate.of(2026, 8, 3); + + PeakStatViewModel peak = tracker.updateAndGet( + PeakMetric.DAU, + List.of(currentPeriodStart.minusDays(2), currentPeriodStart.minusDays(1), currentPeriodStart), + List.of(4L, 9L, 9L), + currentPeriodStart + ); + + assertThat(store.find(PeakMetric.DAU)) + .contains(new PeakStat(9L, currentPeriodStart)); + assertThat(peak).isEqualTo(new PeakStatViewModel( + true, 9L, "2026-08-03", "2026-08-03", true + )); + } + + @Test + void updateAndGet_keeps_greater_historical_peak() { + LocalDate historicalPeriod = LocalDate.of(2026, 7, 1); + store.updateIfGreater(PeakMetric.DAU, new PeakStat(20L, historicalPeriod)); + + PeakStatViewModel peak = tracker.updateAndGet( + PeakMetric.DAU, + List.of(LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 2)), + List.of(8L, 10L), + LocalDate.of(2026, 8, 2) + ); + + assertThat(peak.value()).isEqualTo(20L); + assertThat(peak.periodStart()).isEqualTo("2026-07-01"); + assertThat(peak.currentPeriod()).isFalse(); + } + + @Test + void updateAndGet_formats_week_and_month_periods() { + PeakStatViewModel weeklyPeak = tracker.updateAndGet( + PeakMetric.WAU, + List.of(LocalDate.of(2026, 8, 10)), + List.of(10L), + LocalDate.of(2026, 8, 10) + ); + PeakStatViewModel monthlyPeak = tracker.updateAndGet( + PeakMetric.MAU, + List.of(LocalDate.of(2026, 8, 1)), + List.of(20L), + LocalDate.of(2026, 8, 1) + ); + + assertThat(weeklyPeak.periodLabel()).isEqualTo("2026-08-10 ~ 2026-08-16"); + assertThat(monthlyPeak.periodLabel()).isEqualTo("2026-08"); + } + + @Test + void updateAndGet_returns_empty_view_model_when_all_values_are_zero() { + PeakStatViewModel peak = tracker.updateAndGet( + PeakMetric.DAILY_SIGNUP, + List.of(LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 2)), + List.of(0L, 0L), + LocalDate.of(2026, 8, 2) + ); + + assertThat(peak).isEqualTo(PeakStatViewModel.empty()); + assertThat(store.find(PeakMetric.DAILY_SIGNUP)).isEmpty(); + } + + @Test + void updateAndGet_rejects_different_period_and_value_sizes() { + assertThatThrownBy(() -> tracker.updateAndGet( + PeakMetric.DAU, + List.of(LocalDate.of(2026, 8, 1)), + List.of(1L, 2L), + LocalDate.of(2026, 8, 1) + )).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java new file mode 100644 index 00000000..a6af4f84 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java @@ -0,0 +1,144 @@ +package com.devkor.ifive.nadab.domain.stats.controller; + +import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthInterceptor; +import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; +import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; +import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; +import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.global.security.filter.JwtAuthenticationFilter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StatsController.class) +@AutoConfigureMockMvc(addFilters = false) +class StatsControllerTemplateTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DailyStatsService dailyStatsService; + @MockitoBean + private WeeklyStatsService weeklyStatsService; + @MockitoBean + private MonthlyStatsService monthlyStatsService; + @MockitoBean + private TotalStatsService totalStatsService; + @MockitoBean + private TypeStatsService typeStatsService; + @MockitoBean + private WithdrawalStatsService withdrawalStatsService; + @MockitoBean + private AdminPageAuthInterceptor adminPageAuthInterceptor; + @MockitoBean + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Test + void dailyStats_renders_peak_value_period_current_badge_and_empty_state() throws Exception { + PeakStatViewModel currentPeak = peak(1_234L, "2026-08-13", true); + PeakStatViewModel pastPeak = peak(900L, "2026-08-12", false); + when(dailyStatsService.getDailyStatsLast7Days()).thenReturn(new DailyStatsViewModel( + List.of("2026-08-13"), + List.of(1L), + List.of(2L), + List.of(3L), + currentPeak, + pastPeak, + PeakStatViewModel.empty(), + 0L, + "2026-08-13 12:00:00" + )); + + mockMvc.perform(get("/stats/daily")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("/css/stats-peak.css"))) + .andExpect(content().string(containsString("역대 최고"))) + .andExpect(content().string(containsString("1,234"))) + .andExpect(content().string(containsString("2026-08-12"))) + .andExpect(content().string(containsString("현재 기간"))) + .andExpect(content().string(containsString("기록 없음"))); + } + + @Test + void weeklyStats_renders_weekly_peak_period() throws Exception { + PeakStatViewModel weeklyPeak = peak(80L, "2026-08-10 ~ 2026-08-16", false); + when(weeklyStatsService.getWeeklyStatsLast7Weeks()).thenReturn(new WeeklyStatsViewModel( + List.of("08-10 ~ 08-16"), + List.of(1L), + List.of(2L), + List.of(3L), + List.of(4L), + List.of(5L), + weeklyPeak, + weeklyPeak, + weeklyPeak, + weeklyPeak, + weeklyPeak, + 0L, + "2026-08-13 12:00:00" + )); + + mockMvc.perform(get("/stats/weekly")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("2026-08-10 ~ 2026-08-16"))) + .andExpect(content().string(containsString("WAU"))); + } + + @Test + void monthlyStats_renders_monthly_peak_period() throws Exception { + PeakStatViewModel monthlyPeak = peak(120L, "2026-08", false); + when(monthlyStatsService.getMonthlyStatsLast5Months()).thenReturn(new MonthlyStatsViewModel( + List.of("2026-08"), + List.of(1L), + List.of(2L), + List.of(3L), + List.of(4L), + List.of(5L), + List.of(9L), + List.of(6L), + monthlyPeak, + monthlyPeak, + monthlyPeak, + monthlyPeak, + monthlyPeak, + 0L, + 0L, + 0L, + "2026-08-13 12:00:00" + )); + + mockMvc.perform(get("/stats/monthly")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("2026-08"))) + .andExpect(content().string(containsString("MAU"))); + } + + @Test + void peakStylesheet_is_served_as_static_resource() throws Exception { + mockMvc.perform(get("/css/stats-peak.css")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(".peak-record-current"))); + } + + private PeakStatViewModel peak(long value, String periodLabel, boolean currentPeriod) { + return new PeakStatViewModel(true, value, "2026-08-01", periodLabel, currentPeriod); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepositoryTest.java new file mode 100644 index 00000000..7334512d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepositoryTest.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContentFactory; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(MonthlyStatsRepository.class) +class MonthlyStatsRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + MonthlyStatsRepository monthlyStatsRepository; + + @Autowired + TestEntityManager em; + + @Test + void counts_completed_and_in_progress_monthly_reports_by_version() { + LocalDate reportDate = LocalDate.of(2026, 8, 1); + + em.persist(v1Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.COMPLETED)); + em.persist(v1Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.IN_PROGRESS)); + em.persist(v2Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.COMPLETED)); + em.persist(v2Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.IN_PROGRESS)); + em.flush(); + em.clear(); + + List completedV1 = monthlyStatsRepository + .findCompletedMonthlyReportV1CountsByDateBetween(reportDate, reportDate); + List completedV2 = monthlyStatsRepository + .findCompletedMonthlyReportV2CountsByDateBetween(reportDate, reportDate); + + assertThat(completedV1).containsExactly(new DateCountDto(reportDate, 1L)); + assertThat(completedV2).containsExactly(new DateCountDto(reportDate, 1L)); + assertThat(monthlyStatsRepository.countInProgressMonthlyReportV1Now()).isEqualTo(1L); + assertThat(monthlyStatsRepository.countInProgressMonthlyReportV2Now()).isEqualTo(1L); + } + + private MonthlyReport v1Report(User user, LocalDate reportDate, MonthlyReportStatus status) { + LocalDate monthStartDate = reportDate.minusMonths(1).withDayOfMonth(1); + return MonthlyReport.create( + user, + monthStartDate, + monthStartDate.withDayOfMonth(monthStartDate.lengthOfMonth()), + ReportContentFactory.empty(), + reportDate, + status + ); + } + + private MonthlyReportV2 v2Report(User user, LocalDate reportDate, MonthlyReportStatus status) { + LocalDate monthStartDate = reportDate.minusMonths(1).withDayOfMonth(1); + return MonthlyReportV2.create( + user, + monthStartDate, + monthStartDate.withDayOfMonth(monthStartDate.lengthOfMonth()), + MonthlyReportV2ContentFactory.empty(), + reportDate, + status, + MonthlyReportImageStatus.PENDING, + MonthlyReportComparisonType.BASELINE + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepositoryTest.java new file mode 100644 index 00000000..67c5c6fb --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepositoryTest.java @@ -0,0 +1,192 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReport; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReportStatus; +import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContentFactory; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(PeakStatsRepository.class) +class PeakStatsRepositoryTest extends PostgresIntegrationTestSupport { + + private static final LocalDate JANUARY_4 = LocalDate.of(2026, 1, 4); + private static final LocalDate JANUARY_5 = LocalDate.of(2026, 1, 5); + private static final LocalDate FEBRUARY_1 = LocalDate.of(2026, 2, 1); + private static final LocalDate FUTURE_DATE = LocalDate.of(2100, 1, 4); + + @Autowired + PeakStatsRepository repository; + + @Autowired + TestEntityManager em; + + @Test + void findAllPeakStats_returns_empty_map_when_no_statistics_exist() { + assertThat(repository.findAllPeakStats()).isEmpty(); + } + + @Test + void findAllPeakStats_aggregates_all_metrics_and_uses_latest_period_for_ties() { + User first = completedUser(OffsetDateTime.parse("2026-01-03T15:30:00Z")); + User second = completedUser(OffsetDateTime.parse("2026-01-04T10:00:00+09:00")); + User third = completedUser(OffsetDateTime.parse("2026-01-04T15:30:00Z")); + User fourth = completedUser(OffsetDateTime.parse("2026-01-05T10:00:00+09:00")); + incompleteUser(OffsetDateTime.parse("2026-01-05T11:00:00+09:00")); + DailyQuestion question = em.getEntityManager().getReference(DailyQuestion.class, 1L); + + assignedQuestion(first, question, JANUARY_4); + assignedQuestion(second, question, JANUARY_4); + assignedQuestion(third, question, JANUARY_5); + assignedQuestion(fourth, question, JANUARY_5); + + dailyReport(first, question, JANUARY_4, DailyReportStatus.COMPLETED); + dailyReport(second, question, JANUARY_4, DailyReportStatus.COMPLETED); + dailyReport(first, question, JANUARY_5, DailyReportStatus.COMPLETED); + dailyReport(second, question, JANUARY_5, DailyReportStatus.COMPLETED); + dailyReport(first, question, LocalDate.of(2026, 1, 6), DailyReportStatus.COMPLETED); + dailyReport(first, question, FEBRUARY_1, DailyReportStatus.COMPLETED); + dailyReport(second, question, FEBRUARY_1, DailyReportStatus.COMPLETED); + dailyReport(third, question, LocalDate.of(2026, 2, 2), DailyReportStatus.FAILED); + + weeklyReport(first, JANUARY_4, WeeklyReportStatus.COMPLETED); + weeklyReport(first, JANUARY_5, WeeklyReportStatus.COMPLETED); + weeklyReport(second, JANUARY_5, WeeklyReportStatus.COMPLETED); + weeklyReport(third, LocalDate.of(2026, 1, 6), WeeklyReportStatus.FAILED); + + monthlyReportV1(first, LocalDate.of(2026, 1, 15), MonthlyReportStatus.COMPLETED); + monthlyReportV2(second, LocalDate.of(2026, 1, 20), MonthlyReportStatus.COMPLETED); + monthlyReportV1(first, LocalDate.of(2026, 2, 5), MonthlyReportStatus.COMPLETED); + monthlyReportV2(second, LocalDate.of(2026, 2, 6), MonthlyReportStatus.COMPLETED); + monthlyReportV1(third, LocalDate.of(2026, 3, 5), MonthlyReportStatus.FAILED); + createFutureStatistics(question); + em.flush(); + em.clear(); + + Map peaks = repository.findAllPeakStats(); + + assertThat(peaks).hasSize(PeakMetric.values().length); + assertThat(peaks) + .containsEntry(PeakMetric.DAILY_SIGNUP, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.DAILY_ASSIGNED_QUESTION, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.DAU, peak(2L, FEBRUARY_1)) + .containsEntry(PeakMetric.WEEKLY_SIGNUP, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.WEEKLY_ASSIGNED_QUESTION, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.WEEKLY_DAILY_REPORT, peak(3L, JANUARY_5)) + .containsEntry(PeakMetric.WEEKLY_REPORT, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.WAU, peak(2L, LocalDate.of(2026, 1, 26))) + .containsEntry(PeakMetric.MONTHLY_SIGNUP, peak(4L, LocalDate.of(2026, 1, 1))) + .containsEntry(PeakMetric.MONTHLY_ASSIGNED_QUESTION, peak(4L, LocalDate.of(2026, 1, 1))) + .containsEntry(PeakMetric.MONTHLY_DAILY_REPORT, peak(5L, LocalDate.of(2026, 1, 1))) + .containsEntry(PeakMetric.MONTHLY_REPORT, peak(2L, LocalDate.of(2026, 2, 1))) + .containsEntry(PeakMetric.MAU, peak(2L, LocalDate.of(2026, 2, 1))); + } + + private void createFutureStatistics(DailyQuestion question) { + User first = completedUser(OffsetDateTime.parse("2100-01-04T10:00:00+09:00")); + User second = completedUser(OffsetDateTime.parse("2100-01-04T11:00:00+09:00")); + User third = completedUser(OffsetDateTime.parse("2100-01-04T12:00:00+09:00")); + + for (User user : new User[]{first, second, third}) { + assignedQuestion(user, question, FUTURE_DATE); + dailyReport(user, question, FUTURE_DATE, DailyReportStatus.COMPLETED); + weeklyReport(user, FUTURE_DATE, WeeklyReportStatus.COMPLETED); + } + monthlyReportV1(first, FUTURE_DATE, MonthlyReportStatus.COMPLETED); + monthlyReportV1(second, FUTURE_DATE, MonthlyReportStatus.COMPLETED); + monthlyReportV2(third, FUTURE_DATE, MonthlyReportStatus.COMPLETED); + } + + private User completedUser(OffsetDateTime registeredAt) { + User user = new UserBuilder(em).build(); + user.updateSignupStatus(SignupStatusType.COMPLETED); + ReflectionTestUtils.setField(user, "registeredAt", registeredAt); + return user; + } + + private void incompleteUser(OffsetDateTime registeredAt) { + User user = new UserBuilder(em).build(); + ReflectionTestUtils.setField(user, "registeredAt", registeredAt); + } + + private void assignedQuestion(User user, DailyQuestion question, LocalDate date) { + em.persist(UserDailyQuestion.create(user, date, question)); + } + + private void dailyReport(User user, DailyQuestion question, LocalDate date, DailyReportStatus status) { + AnswerEntry answer = AnswerEntry.create(user, question, "answer", date, null); + em.persist(answer); + em.persist(DailyReport.create(answer, null, "report", date, status)); + } + + private void weeklyReport(User user, LocalDate date, WeeklyReportStatus status) { + LocalDate weekStart = date.minusDays(date.getDayOfWeek().getValue() - 1L); + em.persist(WeeklyReport.create( + user, + weekStart, + weekStart.plusDays(6), + ReportContentFactory.empty(), + date, + status + )); + } + + private void monthlyReportV1(User user, LocalDate date, MonthlyReportStatus status) { + LocalDate targetMonth = date.minusMonths(1).withDayOfMonth(1); + em.persist(MonthlyReport.create( + user, + targetMonth, + targetMonth.withDayOfMonth(targetMonth.lengthOfMonth()), + ReportContentFactory.empty(), + date, + status + )); + } + + private void monthlyReportV2(User user, LocalDate date, MonthlyReportStatus status) { + LocalDate targetMonth = date.minusMonths(1).withDayOfMonth(1); + em.persist(MonthlyReportV2.create( + user, + targetMonth, + targetMonth.withDayOfMonth(targetMonth.lengthOfMonth()), + MonthlyReportV2ContentFactory.empty(), + date, + status, + MonthlyReportImageStatus.PENDING, + MonthlyReportComparisonType.BASELINE + )); + } + + private PeakStat peak(long value, LocalDate periodStart) { + return new PeakStat(value, periodStart); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStoreTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStoreTest.java new file mode 100644 index 00000000..102a7c7b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStoreTest.java @@ -0,0 +1,114 @@ +package com.devkor.ifive.nadab.domain.stats.infra; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InMemoryPeakStatsStoreTest { + + private final InMemoryPeakStatsStore store = new InMemoryPeakStatsStore(); + + @Test + void updateIfGreater_stores_first_positive_record() { + PeakStat peak = new PeakStat(10L, LocalDate.of(2026, 8, 1)); + + store.updateIfGreater(PeakMetric.DAU, peak); + + assertThat(store.find(PeakMetric.DAU)).contains(peak); + } + + @Test + void updateIfGreater_replaces_record_with_greater_value() { + store.updateIfGreater(PeakMetric.DAU, new PeakStat(10L, LocalDate.of(2026, 8, 1))); + PeakStat greater = new PeakStat(11L, LocalDate.of(2026, 8, 2)); + + store.updateIfGreater(PeakMetric.DAU, greater); + + assertThat(store.find(PeakMetric.DAU)).contains(greater); + } + + @Test + void updateIfGreater_keeps_record_when_candidate_is_smaller() { + PeakStat current = new PeakStat(10L, LocalDate.of(2026, 8, 1)); + store.updateIfGreater(PeakMetric.DAU, current); + + store.updateIfGreater(PeakMetric.DAU, new PeakStat(9L, LocalDate.of(2026, 8, 2))); + + assertThat(store.find(PeakMetric.DAU)).contains(current); + } + + @Test + void updateIfGreater_uses_latest_period_when_values_are_equal() { + store.updateIfGreater(PeakMetric.DAU, new PeakStat(10L, LocalDate.of(2026, 8, 1))); + PeakStat latest = new PeakStat(10L, LocalDate.of(2026, 8, 2)); + + store.updateIfGreater(PeakMetric.DAU, latest); + + assertThat(store.find(PeakMetric.DAU)).contains(latest); + } + + @Test + void updateIfGreater_keeps_latest_period_when_equal_candidate_is_older() { + PeakStat latest = new PeakStat(10L, LocalDate.of(2026, 8, 2)); + store.updateIfGreater(PeakMetric.DAU, latest); + + store.updateIfGreater(PeakMetric.DAU, new PeakStat(10L, LocalDate.of(2026, 8, 1))); + + assertThat(store.find(PeakMetric.DAU)).contains(latest); + } + + @Test + void updateIfGreater_ignores_zero_value() { + store.updateIfGreater(PeakMetric.DAU, new PeakStat(0L, LocalDate.of(2026, 8, 1))); + + assertThat(store.find(PeakMetric.DAU)).isEmpty(); + } + + @Test + void updateIfGreater_keeps_greatest_value_during_concurrent_updates() + throws InterruptedException, ExecutionException { + int candidateCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(8); + List> updates = new ArrayList<>(); + for (int value = 1; value <= candidateCount; value++) { + int candidateValue = value; + updates.add(() -> { + store.updateIfGreater( + PeakMetric.DAU, + new PeakStat(candidateValue, LocalDate.of(2026, 8, 1).plusDays(candidateValue)) + ); + return null; + }); + } + + try { + List> futures = executor.invokeAll(updates); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertThat(store.find(PeakMetric.DAU)) + .contains(new PeakStat(candidateCount, LocalDate.of(2026, 8, 1).plusDays(candidateCount))); + } + + @Test + void peakStat_rejects_negative_value() { + assertThatThrownBy(() -> new PeakStat(-1L, LocalDate.of(2026, 8, 1))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 7523e691..fd7e1744 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -20,7 +20,7 @@ ask-chat: answer: provider: OPENAI model: gpt-4o-mini - temperature: 0.3 + temperature: 1.0 max-tokens: 700 recent-message-limit: 10 follow-up-question-count: 2