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
@@ -0,0 +1,20 @@
package com.cocky.cockyserver.ai.port;

/**
* 익명 닉네임 생성 최종 실패 계약 예외. {@link NicknameGenerator#generate} 호출 실패든, 중복
* 회피 재시도(최대 5회) 소진이든 이 하나로 통일해서 던진다. 백엔드는 이 타입만 캐치하면 된다.
*
* <p>{@link PeriodFeedbackFailedException}과 같은 위치·패턴. 기본 닉네임으로의 폴백은 의도적으로
* 만들지 않는다 — 실패는 실패로 노출한다(단계 3).
*
* <p>⚠️ 현재 실구현({@code NicknameService})은 OpenAI 호출 실패 시 내부에서 내장 예시로 폴백해
* 예외를 던지지 않는다(재시도 없이 항상 성공 반환) — 이 포트가 "AI 호출 실패"로 이 예외를 던지는
* 경로는 지금은 구조적으로만 존재하고 real 구현체에서는 발동하지 않는다. {@code NicknameService}는
* 이번 작업 범위 밖이라 손대지 않았다(PR 리뷰 시 재논의 필요).
*/
public class NicknameGenerationFailedException extends RuntimeException {

public NicknameGenerationFailedException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
package com.cocky.cockyserver.domain.ranking.dto;

import com.cocky.cockyserver.domain.ranking.entity.RankingSnapshot;
import com.cocky.cockyserver.domain.user.entity.User;
import java.math.BigDecimal;

public record RankingEntryResponse(Integer rank, Long userId, String nickname, BigDecimal score) {

/**
* 이름은 스냅샷에 저장돼 있지 않고 {@code snapshot.getUser()}(FK 연관관계)로 매번 조회 시점의
* User를 읽어온다 — ranking_snapshot 테이블 자체가 이름을 따로 보관하지 않으므로 "스냅샷
* 시점 값 vs 조회 시점 값" 선택의 여지가 원래 없었다(항상 조회 시점 값). 익명 여부도 같은
* 원칙을 그대로 따른다: user.isAnonymous()가 true면 anonymous_nickname, false면 실명.
* (toggle API가 항상 nickname을 먼저 채운 뒤 anonymous를 켜므로 anonymous=true인데
* anonymous_nickname이 null인 상태는 정상 경로에서 발생하지 않는다.)
*/
public static RankingEntryResponse from(RankingSnapshot snapshot) {
return new RankingEntryResponse(
snapshot.getRank(), snapshot.getUser().getId(), snapshot.getUser().getName(), snapshot.getScore());
User user = snapshot.getUser();
String nickname = user.isAnonymous() ? user.getAnonymousNickname() : user.getName();
return new RankingEntryResponse(snapshot.getRank(), user.getId(), nickname, snapshot.getScore());
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.cocky.cockyserver.domain.user.controller;

import com.cocky.cockyserver.domain.user.dto.AnonymousToggleRequest;
import com.cocky.cockyserver.domain.user.dto.AnonymousToggleResponse;
import com.cocky.cockyserver.domain.user.dto.UserMeResponse;
import com.cocky.cockyserver.domain.user.service.UserService;
import com.cocky.cockyserver.global.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -21,4 +26,11 @@ public class UserController {
public ResponseEntity<UserMeResponse> getMe(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(userService.getMe(principal.userId()));
}

/** 본인만 호출 가능 — /me 경로라 principal의 userId 외 대상을 지정할 방법이 없다. */
@PatchMapping("/me/anonymous")
public ResponseEntity<AnonymousToggleResponse> toggleAnonymous(
@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody AnonymousToggleRequest request) {
return ResponseEntity.ok(userService.setAnonymous(principal.userId(), request.anonymous()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.cocky.cockyserver.domain.user.dto;

import jakarta.validation.constraints.NotNull;

public record AnonymousToggleRequest(
@NotNull(message = "anonymous는 필수입니다.") Boolean anonymous
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.cocky.cockyserver.domain.user.dto;

import com.cocky.cockyserver.domain.user.entity.User;

public record AnonymousToggleResponse(boolean anonymous, String anonymousNickname) {

public static AnonymousToggleResponse from(User user) {
return new AnonymousToggleResponse(user.isAnonymous(), user.getAnonymousNickname());
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/cocky/cockyserver/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public class User {
@Column(name = "refresh_token", length = 512)
private String refreshToken;

@Column(name = "anonymous_nickname", length = 50, unique = true)
private String anonymousNickname;

@Column(name = "is_anonymous", nullable = false)
private boolean anonymous;

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
Expand Down Expand Up @@ -91,4 +97,13 @@ public void updateRefreshToken(String refreshToken) {
public void clearRefreshToken() {
this.refreshToken = null;
}

/** anonymousNickname이 이미 있으면 재생성하지 않고 그대로 둔다 — 호출부는 null일 때만 넘긴다. */
public void updateAnonymousNickname(String anonymousNickname) {
this.anonymousNickname = anonymousNickname;
}

public void updateAnonymous(boolean anonymous) {
this.anonymous = anonymous;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@
public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findByDatagsmId(Long datagsmId);

/** 익명 닉네임 생성 시 중복 확인용(단계 3). */
boolean existsByAnonymousNickname(String anonymousNickname);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.cocky.cockyserver.domain.user.service;

import com.cocky.cockyserver.ai.port.NicknameGenerationFailedException;
import com.cocky.cockyserver.ai.port.NicknameGenerator;
import com.cocky.cockyserver.domain.user.dto.AnonymousToggleResponse;
import com.cocky.cockyserver.domain.user.dto.UserMeResponse;
import com.cocky.cockyserver.domain.user.entity.User;
import com.cocky.cockyserver.domain.user.exception.UserNotFoundException;
import com.cocky.cockyserver.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -13,11 +18,62 @@
@Transactional(readOnly = true)
public class UserService {

private static final Logger log = LoggerFactory.getLogger(UserService.class);

/** NicknameGenerator.generate()가 인자 없이 무작위 생성이라 중복 가능 — 총 시도 횟수(재시도 아님). */
private static final int MAX_NICKNAME_ATTEMPTS = 5;

private final UserRepository userRepository;
private final NicknameGenerator nicknameGenerator;

public UserMeResponse getMe(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("존재하지 않는 사용자입니다. userId=" + userId));
return UserMeResponse.from(user);
}

/**
* 익명 모드 토글. true로 켤 때 anonymous_nickname이 아직 없으면
* {@link NicknameGenerator#generate}로 채우고, 이미 있으면 재생성하지 않고 재사용한다.
* false로 끌 때는 is_anonymous만 내리고 닉네임은 보존한다(재도입 시 재사용).
*
* <p>회원가입 경로에서는 절대 호출하지 않는다 — AI 의존성이 신규 가입을 막게 되는 것을
* 피하기 위해 이 토글 API에서만 온디맨드로 호출한다.
*/
@Transactional
public AnonymousToggleResponse setAnonymous(Long userId, boolean anonymous) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("존재하지 않는 사용자입니다. userId=" + userId));

if (anonymous && user.getAnonymousNickname() == null) {
user.updateAnonymousNickname(generateUniqueNickname());
}
user.updateAnonymous(anonymous);

return AnonymousToggleResponse.from(user);
}

/**
* 생성 → unique 조회 → 중복이면 재생성, 최대 {@link #MAX_NICKNAME_ATTEMPTS}회. AI 호출
* 실패든 중복 소진이든 {@link NicknameGenerationFailedException} 하나로 통일해서 던진다 —
* 기본 닉네임 폴백은 만들지 않는다(실패는 실패로 노출).
*/
private String generateUniqueNickname() {
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_NICKNAME_ATTEMPTS; attempt++) {
try {
String candidate = nicknameGenerator.generate();
if (!userRepository.existsByAnonymousNickname(candidate)) {
return candidate;
}
log.warn("익명 닉네임 중복(시도 {}/{}): {}", attempt, MAX_NICKNAME_ATTEMPTS, candidate);
} catch (RuntimeException e) {
last = e;
log.warn("익명 닉네임 생성 시도 {}/{} 실패: {}", attempt, MAX_NICKNAME_ATTEMPTS, e.getMessage());
}
}
log.error("익명 닉네임 총 {}회 시도 소진", MAX_NICKNAME_ATTEMPTS);
throw new NicknameGenerationFailedException(
"익명 닉네임 생성 실패(총 " + MAX_NICKNAME_ATTEMPTS + "회 시도 소진)", last);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.cocky.cockyserver.global.exception;

import com.cocky.cockyserver.ai.port.NicknameGenerationFailedException;
import com.cocky.cockyserver.ai.port.PeriodFeedbackFailedException;
import com.cocky.cockyserver.domain.auth.exception.OAuthCodeInvalidException;
import com.cocky.cockyserver.domain.auth.exception.OAuthServerException;
Expand Down Expand Up @@ -108,6 +109,13 @@ public ResponseEntity<ErrorResponse> handlePeriodFeedbackFailed(PeriodFeedbackFa
.body(new ErrorResponse("PERIOD_FEEDBACK_FAILED", e.getMessage()));
}

/** ai.port.NicknameGenerator#generate 실패(AI 호출/중복 5회 소진 통일) — 단계 3. */
@ExceptionHandler(NicknameGenerationFailedException.class)
public ResponseEntity<ErrorResponse> handleNicknameGenerationFailed(NicknameGenerationFailedException e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse("NICKNAME_GENERATION_FAILED", e.getMessage()));
}

@ExceptionHandler(TestCaseNotConfiguredException.class)
public ResponseEntity<ErrorResponse> handleTestCaseNotConfigured(TestCaseNotConfiguredException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- 랭킹 등에서 본명 대신 노출할 수 있는 익명 닉네임 토글. anonymous_nickname은 AI가 생성해
-- 한 번 채워지면 고정(재생성 안 함) — is_anonymous만 켜고 끄는 방식이라 토글 자체는 자유롭다.
-- 기존 행은 anonymous_nickname NULL / is_anonymous FALSE(컬럼 기본값)로 채워진다.
ALTER TABLE `user`
ADD COLUMN anonymous_nickname VARCHAR(50) NULL AFTER refresh_token,
ADD COLUMN is_anonymous BOOLEAN NOT NULL DEFAULT FALSE AFTER anonymous_nickname,
ADD CONSTRAINT uk_user_anonymous_nickname UNIQUE (anonymous_nickname);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.cocky.cockyserver.domain.ranking.dto;

import com.cocky.cockyserver.domain.ranking.entity.RankingSnapshot;
import com.cocky.cockyserver.domain.ranking.entity.ScopeType;
import com.cocky.cockyserver.domain.user.entity.Role;
import com.cocky.cockyserver.domain.user.entity.User;
import com.cocky.cockyserver.global.entity.PeriodType;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

import static org.junit.jupiter.api.Assertions.assertEquals;

/**
* 단계 3: 랭킹 응답의 nickname이 user.isAnonymous()에 따라 달라지는지 검증.
* ranking_snapshot이 이름을 직접 저장하지 않고 snapshot.getUser()로 조회 시점 값을 읽으므로
* (RankingEntryResponse.from Javadoc 참고) User 엔티티 상태만 바꿔가며 확인하면 된다.
*/
class RankingEntryResponseTest {

private User user(String name) {
User user = new User(1L, "user@gsm.hs.kr", name, 2, 3, 1, "SW과", Role.STUDENT);
ReflectionTestUtils.setField(user, "id", 1L);
return user;
}

private RankingSnapshot snapshotFor(User user) {
return new RankingSnapshot(user, null, PeriodType.WEEKLY, ScopeType.SCHOOL,
null, null, "2026-07-06", 1, new BigDecimal("87.50"), LocalDateTime.now());
}

@Test
void 익명이_아니면_실명을_반환한다() {
User user = user("홍길동");

RankingEntryResponse response = RankingEntryResponse.from(snapshotFor(user));

assertEquals("홍길동", response.nickname());
}

@Test
void 익명이면_anonymous_nickname을_반환한다() {
User user = user("홍길동");
user.updateAnonymousNickname("파란 재귀함수");
user.updateAnonymous(true);

RankingEntryResponse response = RankingEntryResponse.from(snapshotFor(user));

assertEquals("파란 재귀함수", response.nickname());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.cocky.cockyserver.domain.user.controller;

import com.cocky.cockyserver.ai.port.NicknameGenerationFailedException;
import com.cocky.cockyserver.domain.user.dto.AnonymousToggleResponse;
import com.cocky.cockyserver.domain.user.entity.Role;
import com.cocky.cockyserver.domain.user.service.UserService;
import com.cocky.cockyserver.global.exception.GlobalExceptionHandler;
import com.cocky.cockyserver.global.security.UserPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
* PATCH /api/v1/users/me/anonymous 슬라이스 테스트. FeedbackControllerTest와 동일하게
* standalone MockMvc + 커스텀 ArgumentResolver로 @AuthenticationPrincipal을 흉내 낸다.
*/
class UserControllerTest {

private static final Long USER_ID = 1L;

private UserService userService;
private MockMvc mockMvc;

@BeforeEach
void setUp() {
userService = mock(UserService.class);
UserPrincipal principal = new UserPrincipal(USER_ID, Role.STUDENT);
mockMvc = MockMvcBuilders.standaloneSetup(new UserController(userService))
.setCustomArgumentResolvers(new UserPrincipalArgumentResolver(principal))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}

@Test
void anonymous_true_요청시_200과_닉네임이_내려온다() throws Exception {
when(userService.setAnonymous(eq(USER_ID), eq(true)))
.thenReturn(new AnonymousToggleResponse(true, "파란 재귀함수"));

mockMvc.perform(patch("/api/v1/users/me/anonymous")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"anonymous\": true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.anonymous").value(true))
.andExpect(jsonPath("$.anonymousNickname").value("파란 재귀함수"));
}

@Test
void 닉네임_생성이_실패하면_503_NICKNAME_GENERATION_FAILED가_내려온다() throws Exception {
when(userService.setAnonymous(eq(USER_ID), anyBoolean()))
.thenThrow(new NicknameGenerationFailedException(
"익명 닉네임 생성 실패(총 5회 시도 소진)", null));

mockMvc.perform(patch("/api/v1/users/me/anonymous")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"anonymous\": true}"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("NICKNAME_GENERATION_FAILED"));
}

@Test
void anonymous_필드가_없으면_400이_내려온다() throws Exception {
mockMvc.perform(patch("/api/v1/users/me/anonymous")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}

private static class UserPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

private final UserPrincipal principal;

UserPrincipalArgumentResolver(UserPrincipal principal) {
this.principal = principal;
}

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(UserPrincipal.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
return principal;
}
}
}
Loading
Loading