Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class QuestionV2JpaEntity {
@JoinColumn(name = "fk_chapter_id", nullable = false)
private ChapterV2JpaEntity chapter;

@Column(nullable = false, length = 1000)
@Column(nullable = false, length = 2000)
private String content;

private String explanation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class CreateQuestionV2RequestDocument {
@Schema(description = "챕터 ID", example = "1")
public Long chapterId;

@Schema(description = "문제 내용", example = "자바의 특징이 아닌 것은?")
@Schema(description = "문제 내용", example = "자바의 특징이 아닌 것은?", maxLength = 2000)
public String content;

@Schema(description = "해설", example = "자바는 객체지향 언어입니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
@Schema(description = "문제 수정 요청")
public class UpdateQuestionV2RequestDocument {

@Schema(description = "문제 내용", example = "자바의 주요 특징은?")
@Schema(description = "문제 내용", example = "자바의 주요 특징은?", maxLength = 2000)
public String content;

@Schema(description = "해설", example = "자바는 객체지향, 플랫폼 독립적, 멀티스레드를 지원합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class CreateQuestionV2Dto {

Expand All @@ -14,6 +15,7 @@ public record Request(
Long chapterId,

@NotBlank(message = "content는 필수 입력값입니다.")
@Size(max = 2000, message = "content는 최대 2000자까지 입력할 수 있습니다.")
String content,

String explanation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class UpdateQuestionV2Dto {

@Schema(name = "UpdateQuestionV2DtoRequest")
public record Request(
@NotBlank(message = "content는 필수 입력값입니다.")
@Size(max = 2000, message = "content는 최대 2000자까지 입력할 수 있습니다.")
String content,

String explanation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,17 @@
@Slf4j
public class RankingTierUpdateService {

private static final int AURA_EXP_THRESHOLD = 200_000;
private static final int MASTER_EXP_THRESHOLD = 150_000;
private static final int AURA_EXP_THRESHOLD = 150_000;
private static final int MASTER_EXP_THRESHOLD = 100_000;

private final UserRepositoryPort userRepositoryPort;
private final UserRankHistoryRepositoryPort userRankHistoryRepositoryPort;
private final SeasonRepositoryPort seasonRepositoryPort;

/**
* 랭킹 기반 티어(Master, Aura) 업데이트
* - Aura : 1위 && exp >= 200,000
* - Master: 2~3위 && exp >= 150,000 (상위 3등 2명)
* - Aura : 1위 && exp >= 150,000
* - Master: 1~3위 && exp >= 100,000
* - 그 외 : NONE
*/
@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ public enum ExpTier {
UNRANKED, // 0 ~ 999
BRONZE, // 1,000 ~ 9,999
SILVER, // 10,000 ~ 29,999
GOLD, // 30,000 ~ 99,999
DIAMOND; // 100,000+
GOLD, // 30,000 ~ 49,999
DIAMOND; // 50,000+

public static ExpTier fromExp(int exp) {
if (exp >= 100_000) return DIAMOND;
if (exp >= 50_000) return DIAMOND;
if (exp >= 30_000) return GOLD;
if (exp >= 10_000) return SILVER;
if (exp >= 1_000) return BRONZE;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE questions_v2
ALTER COLUMN content TYPE VARCHAR(2000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
UPDATE users
SET current_exp_tier = CASE
WHEN total_exp >= 50000 THEN 'DIAMOND'
WHEN total_exp >= 30000 THEN 'GOLD'
WHEN total_exp >= 10000 THEN 'SILVER'
WHEN total_exp >= 1000 THEN 'BRONZE'
ELSE 'UNRANKED'
END;

WITH ranked_users AS (
SELECT
id,
total_exp,
ROW_NUMBER() OVER (ORDER BY total_exp DESC) AS rank

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

total_exp가 동일한 사용자가 여러 명 있을 경우, ROW_NUMBER()의 결과가 실행할 때마다 달라질 수 있는 비결정적(non-deterministic)인 문제가 있습니다. 이는 마이그레이션 결과와 향후 서비스 로직(RankingTierUpdateService) 간의 순위 불일치를 초래할 수 있으므로, id와 같은 고유 컬럼을 보조 정렬 키로 추가하여 순위를 고정하는 것이 좋습니다.

        ROW_NUMBER() OVER (ORDER BY total_exp DESC, id ASC) AS rank

FROM users
WHERE user_status = 'ACTIVE'
)
UPDATE users u
SET current_rank_tier = CASE
WHEN ru.rank = 1 AND ru.total_exp >= 150000 THEN 'AURA'
WHEN ru.rank <= 3 AND ru.total_exp >= 100000 THEN 'MASTER'
ELSE 'NONE'
END
FROM ranked_users ru
WHERE u.id = ru.id;

UPDATE users
SET current_rank_tier = 'NONE'
WHERE user_status <> 'ACTIVE';
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.process.clash.adapter.web.roadmap.v2.question.controller;

import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.process.clash.adapter.web.common.GlobalExceptionHandler;
import com.process.clash.adapter.web.security.AuthenticatedActor;
import com.process.clash.application.common.actor.Actor;
import com.process.clash.application.roadmap.v2.question.port.in.CreateQuestionV2UseCase;
import com.process.clash.application.roadmap.v2.question.port.in.DeleteQuestionV2UseCase;
import com.process.clash.application.roadmap.v2.question.port.in.UpdateQuestionV2UseCase;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@ExtendWith(MockitoExtension.class)
class AdminQuestionV2ControllerTest {

@Mock
private CreateQuestionV2UseCase createQuestionV2UseCase;

@Mock
private UpdateQuestionV2UseCase updateQuestionV2UseCase;

@Mock
private DeleteQuestionV2UseCase deleteQuestionV2UseCase;

private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;

@BeforeEach
void setUp() {
AdminQuestionV2Controller controller = new AdminQuestionV2Controller(
createQuestionV2UseCase,
updateQuestionV2UseCase,
deleteQuestionV2UseCase
);

LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.afterPropertiesSet();

mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(authenticatedActorResolver())
.setValidator(validator)
.build();
}

@Test
@DisplayName("POST /api/v2/admin/questions 는 content가 2000자를 초과하면 400을 반환한다")
void createQuestion_whenContentExceeds2000Characters_returnsBadRequest() throws Exception {
mockMvc.perform(post("/api/v2/admin/questions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of(
"chapterId", 1L,
"content", "a".repeat(2001),
"explanation", "해설",
"orderIndex", 0,
"difficulty", 1
))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("INVALID_ARGUMENT"))
.andExpect(jsonPath("$.error.details.content").value("content는 최대 2000자까지 입력할 수 있습니다."));

verifyNoInteractions(createQuestionV2UseCase);
}

@Test
@DisplayName("PATCH /api/v2/admin/questions/{questionId} 는 content가 2000자를 초과하면 400을 반환한다")
void updateQuestion_whenContentExceeds2000Characters_returnsBadRequest() throws Exception {
mockMvc.perform(patch("/api/v2/admin/questions/{questionId}", 1L)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of(
"content", "a".repeat(2001),
"explanation", "해설",
"orderIndex", 0,
"difficulty", 1
))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("INVALID_ARGUMENT"))
.andExpect(jsonPath("$.error.details.content").value("content는 최대 2000자까지 입력할 수 있습니다."));

verifyNoInteractions(updateQuestionV2UseCase);
}

private HandlerMethodArgumentResolver authenticatedActorResolver() {
return new HandlerMethodArgumentResolver() {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedActor.class);
}

@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
org.springframework.web.context.request.NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
return new Actor(1L);
}
};
}
}
Loading
Loading