Skip to content
2 changes: 1 addition & 1 deletion docs/auth/onboarding-spec-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- 설치 ID는 활성 계정 간 중복만 차단한다. 탈퇴 계정의 설치와 밴리스트 기기 해시는 확정 차단 근거로 쓰지 않는다. 이상 신호 저장은 문서의 후속 범위다.
- 성별 입력은 MALE/FEMALE만 허용한다. 생일은 서버 검증·저장에만 사용하고 응답에서 제외한다.
- 기기 정보는 전체 또는 일부 누락 가능하다. `device_sync_policies`의 활성 규칙을 우선순위 순으로 평가하고 5분 캐시를 사용한다. 조건에 필요한 기기 값이 없으면 해당 규칙을 건너뛴다. priority 0의 빈 조건 폴백이 없으면 기동을 중단한다. 로그인·가입·리프레시·인증 API가 같은 정책을 사용한다.
- 회원 심사는 제출 커밋 후 동기로 실행한다. 외부 호출 중 DB 잠금을 유지하지 않는다. 상태와 null-safe 콘텐츠 비교로 결과를 반영하며, 별도 심사 작업 테이블·revision·pending 시각은 사용하지 않는다.
- 회원 심사는 제출 커밋 후 실행한다. 프로필 수정은 심사를 기다리고, 가입은 응답을 먼저 보내고 서버 안 작은 대기열(스레드 2·대기 500)에서 심사한다(2026-10-28, LLM 왕복 ~0.9초가 가입 응답을 붙잡던 것). 심사 중엔 본인에게 입력 닉네임·타인에게 임시 닉네임, 거절되면 임시 닉네임과 거절 알림. 대기열이 넘치거나 태스크 종료로 놓친 건 05:00 스캔이 다시 집는다. 외부 호출 중 DB 잠금을 유지하지 않는다. 상태와 null-safe 콘텐츠 비교로 결과를 반영하며, 별도 심사 작업 테이블·revision·pending 시각은 사용하지 않는다.
- 사진은 `profile_image_key` 하나만 저장한다. 타인에게는 APPROVED만, 본인에게는 APPROVED/PENDING만 노출한다. REJECTED는 기본 이미지이고 객체를 정리한다.
- 실패한 심사의 유일한 재발행자는 매일 05:00 KST PENDING 스캔이다. 운영에서 `USER_MODERATION_QUEUE_URL`과 SQS 접근 권한이 필요하다. `MODERATION_LOCAL=true`는 개발·테스트에서만 사용한다.
- `user_activity`를 기존 활동 시각으로 백필한다. 인증된 요청의 활동은 계정 본체와 분리해 날짜가 바뀔 때만 갱신한다. 23일/29일 사전 고지, 30일 방 이탈, 335일 사전 고지, 365일 계정 탈퇴를 적용한다. 고지 기록과 유예 기간이 확인되지 않으면 조치를 미룬다. 휴면 이탈은 감점·재입장 대기 없이 DORMANT로 기록한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,9 @@ private SignupResponse completeSignup(SignupRequest req) {
try {
final int current = attempt;
SignupResponse response = requireNotBanned(transactionTemplate.execute(status -> register(req, tokenConsumed, current)));
User reviewed = moderation.moderate(UUID.fromString(response.user().id()));
return new SignupResponse(response.isNewUser(), response.restored(), response.accessToken(),
response.refreshToken(), response.tokenType(), response.expiresIn(), response.flushIntervalSec(),
UserResponse.from(reviewed, scoreSummaryRepository.findById(reviewed.getId()).orElse(null),
lockOf(reviewed)));
// 가입부터 시킨다 — 닉네임·사진 심사는 응답 뒤에 돈다(LLM 왕복 ~0.9초를 기다리지 않는다).
moderation.moderateLater(UUID.fromString(response.user().id()));
return response;
} catch (DataIntegrityViolationException e) {
// 동시 가입 경합 — 사전 조회 후 다른 요청이 먼저 가입했다.
// 계약: "후발 요청은 기존 유저 로그인으로 수렴"(테크 스펙 4-3).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ public ModerationResult check(byte[] image) {
Map<String, Object> body = Map.of("requests", List.of(Map.of(
"image", Map.of("content", Base64.getEncoder().encodeToString(image)),
"features", List.of(Map.of("type", "SAFE_SEARCH_DETECTION")))));
Response res = http.post().uri(ENDPOINT + "?key={key}", apiKey)
// 키는 헤더로 — 쿼리에 두면 URL 을 남기는 트레이스(X-Ray)·접근 로그에 그대로 찍힌다.
Response res = http.post().uri(ENDPOINT).header("x-goog-api-key", apiKey)
.contentType(MediaType.APPLICATION_JSON).body(body)
.retrieve().body(Response.class);
if (res == null || res.responses() == null || res.responses().isEmpty()) return unavailable("빈 응답");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import java.util.List;
import java.util.UUID;

/** Only the daily sweep publishes user moderation retries; request paths remain synchronous. */
/** Only the daily sweep publishes user moderation retries; request paths review in-process (signup in the background). */
@Component
public class UserModerationQueue {
private static final Logger log = LoggerFactory.getLogger(UserModerationQueue.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.ruleup.ruleup_backend.notification.domain.NotificationType;
import com.ruleup.ruleup_backend.user.UserRepository;
import com.ruleup.ruleup_backend.user.domain.User;
import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -17,10 +18,18 @@
import org.springframework.transaction.support.TransactionTemplate;

import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/** Synchronous review after submission commits. Only the daily sweep retries pending fields. */
/**
* Review after submission commits. Profile edits wait for it; signup hands it to a small background pool
* so the response does not hold a ~0.9s LLM round-trip. Only the daily sweep retries pending fields.
*/
@Service
public class UserModerationService {
private static final Logger log = LoggerFactory.getLogger(UserModerationService.class);
Expand All @@ -31,6 +40,9 @@ public class UserModerationService {
private final ImageStorageService images;
private final EntityManager em;
private final TransactionTemplate tx;
/** 외부 호출 ~1초짜리라 스레드는 적게, 가입이 몰리면 줄을 세운다. */
private final ThreadPoolExecutor background = new ThreadPoolExecutor(2, 2, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500), Thread.ofPlatform().name("user-moderation-", 0).daemon().factory());

public UserModerationService(JdbcTemplate jdbc, UserRepository users, ContentModerationClient client,
NotificationPublisher notifications, ImageStorageService images,
Expand All @@ -42,6 +54,45 @@ public UserModerationService(JdbcTemplate jdbc, UserRepository users, ContentMod
}

public User moderate(UUID userId) {
return moderate(userId, null);
}

/**
* 가입 응답을 붙잡지 않고 뒤에서 심사한다. 심사 중에는 본인에게 입력 닉네임·타인에게 임시 닉네임이 보이고,
* 거절되면 임시 닉네임과 거절 알림이 남는다. 대기열이 가득 차거나 태스크가 내려가 놓친 건 05:00 스캔이 다시 집는다.
*/
public void moderateLater(UUID userId) {
try {
background.execute(() -> {
try { moderate(userId); }
catch (RuntimeException failure) {
log.warn("user_moderation_background_failed userId={} error={}", userId, failure.getClass().getSimpleName());
}
});
} catch (RejectedExecutionException full) {
log.warn("user_moderation_deferred userId={} reason=queue_full", userId);
}
}

/** 뒤에서 도는 심사가 모두 끝날 때까지 기다린다(테스트가 가입 뒤 심사 결과를 전제할 때). */
public boolean awaitBackgroundIdle(Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
while (background.getTaskCount() != background.getCompletedTaskCount()) {
if (System.nanoTime() > deadline) return false;
Thread.sleep(10);
}
return true;
}

/** 종료 때 진행 중인 심사는 마저 끝낸다 — 못 끝낸 건 PENDING 으로 남아 05:00 스캔이 집는다. */
@PreDestroy
void stopBackground() throws InterruptedException {
background.shutdown();
if (!background.awaitTermination(15, TimeUnit.SECONDS)) background.shutdownNow();
}

/** 방금 올린 사진은 손에 든 바이트로 심사한다 — 공개 주소로 되받는 왕복(~0.6초)을 건너뛴다. */
public User moderate(UUID userId, UploadedImage uploaded) {
byte[] id = bytes(userId);
var rows = jdbc.queryForList("SELECT nickname,nickname_status,profile_image_key,profile_image_status " +
"FROM users WHERE id=? AND status<>'WITHDRAWN'", (Object) id);
Expand All @@ -54,7 +105,10 @@ public User moderate(UUID userId) {
String content = (String) submitted.get(column);
ModerationResult verdict;
try {
verdict = nickname ? client.moderateNickname(content) : client.moderateImage(content);
verdict = nickname ? client.moderateNickname(content)
: uploaded != null && uploaded.url().equals(content)
? client.moderateImageBytes(uploaded.bytes(), uploaded.mimeType())
: client.moderateImage(content);
} catch (RuntimeException unavailable) {
log.warn("user_moderation_pending userId={} target={} error={}", userId, column,
unavailable.getClass().getSimpleName());
Expand Down Expand Up @@ -96,6 +150,9 @@ public User moderate(UUID userId) {
});
}

/** 저장한 사진의 서빙 URL 과 원본 바이트. URL 이 지금 제출값과 같을 때만 바이트를 쓴다. */
public record UploadedImage(String url, byte[] bytes, String mimeType) {}

private static byte[] bytes(UUID id) {
return ByteBuffer.allocate(16).putLong(id.getMostSignificantBits()).putLong(id.getLeastSignificantBits()).array();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ private void updateStoredProfile(UUID userId, UpdateProfileRequest req) {
public ProfileImageResponse uploadImage(UUID userId, MultipartFile image) {
// Early check avoids uploading known-invalid changes. Recheck under lock after external I/O.
tx.executeWithoutResult(ignored -> requireImageEditable(loadLocked(userId),Instant.now()));
String url = imageStorage.urlOf(imageStorage.store(image));
var validated = imageStorage.validate(image);
String url = imageStorage.urlOf(imageStorage.storeValidated(validated));
try {
tx.executeWithoutResult(ignored -> {
User user=loadLocked(userId);
Expand All @@ -116,7 +117,8 @@ public ProfileImageResponse uploadImage(UUID userId, MultipartFile image) {
try { imageStorage.deleteByUrl(url); } catch (RuntimeException cleanup) { failed.addSuppressed(cleanup); }
throw failed;
}
User reviewed=moderation.moderate(userId);
User reviewed=moderation.moderate(userId,
new com.ruleup.ruleup_backend.moderation.UserModerationService.UploadedImage(url, validated.bytes(), validated.mimeType()));
return ProfileImageResponse.of(reviewed.visibleProfileImageTo(userId),reviewed.getProfileImageStatus());
}

Expand Down
11 changes: 11 additions & 0 deletions src/test/java/com/ruleup/ruleup_backend/auth/AuthApiSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
import com.ruleup.ruleup_backend.moderation.UserModerationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -169,9 +172,17 @@ protected Map<String, Object> preparedSignup(String tag, String nickname) throws
protected MvcResult signup(String tag, String nickname) throws Exception {
MvcResult res = postJson("/api/v1/auth/signup", preparedSignup(tag, nickname));
assertThat(res.getResponse().getStatus()).isEqualTo(200);
awaitModeration();
return res;
}

@Autowired private UserModerationService userModeration;

/** 가입 심사는 응답 뒤에 돈다 — 승인된 닉네임·조용한 SQL 을 전제하는 테스트는 끝날 때까지 기다린다. */
protected void awaitModeration() throws InterruptedException {
assertThat(userModeration.awaitBackgroundIdle(Duration.ofSeconds(10))).as("가입 심사 대기").isTrue();
}

// ===== 응답 =====

@SuppressWarnings("unchecked")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
Expand Down Expand Up @@ -517,6 +519,21 @@ void upload_via_contract_path() throws Exception {
.isIn(ProfileImageStatus.PENDING, ProfileImageStatus.APPROVED);
}

@Test
@DisplayName("올린 사진은 받은 바이트로 심사한다 — 공개 주소로 되받지 않는다")
void upload_moderates_received_bytes() throws Exception {
String tag = uniq("pb");
String at = read(signup(tag, "바이트" + seq()), "$.data.accessToken");
org.mockito.Mockito.clearInvocations(moderationClient);

mvc.perform(multipart("/api/v1/users/me/profile-image")
.file(new MockMultipartFile("image", "p.png", MediaType.IMAGE_PNG_VALUE, PNG_1X1))
.header("Authorization", "Bearer " + at)).andReturn();

verify(moderationClient).moderateImageBytes(PNG_1X1, MediaType.IMAGE_PNG_VALUE);
verify(moderationClient, never()).moderateImage(anyString());
}

@Test
@DisplayName("이미지가 아닌 파일은 415 IMAGE_INVALID_TYPE")
void upload_rejects_non_image() throws Exception {
Expand Down
26 changes: 22 additions & 4 deletions src/test/java/com/ruleup/ruleup_backend/auth/OnboardingSpecIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,29 @@ class OnboardingSpecIT extends ChallengeApiSupport {
assertThat(second.consume(jti)).isFalse();
}

@Test void signupReturnsTheSynchronousModerationResult() throws Exception {
when(client.moderateNickname(anyString())).thenReturn(ModerationResult.APPROVED);
var result = signup(uniq("sync-moderation"), "동기검수" + uniq("").substring(0, 5));
@Test void signupRespondsBeforeModerationAndAppliesItAfterwards() throws Exception {
CountDownLatch release = new CountDownLatch(1);
when(client.moderateNickname(anyString())).thenAnswer(invocation -> {
assertThat(release.await(10, TimeUnit.SECONDS)).isTrue();
return ModerationResult.APPROVED;
});
String nickname = "뒤검수" + uniq("").substring(0, 5);
// 공용 signup() 은 심사가 끝나길 기다리므로 직접 보낸다 — 여기선 심사가 막힌 채 응답이 와야 한다.
var result = postJson("/api/v1/auth/signup", preparedSignup(uniq("async-moderation"), nickname));
// 심사가 막혀 있어도 가입은 끝난다 — 본인에게는 입력한 닉네임이 심사 중으로 보인다.
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat((String) read(result, "$.data.user.nicknameStatus")).isEqualTo("APPROVED");
assertThat((String) read(result, "$.data.user.nicknameStatus")).isEqualTo("PENDING");
assertThat((String) read(result, "$.data.user.nickname")).isEqualTo(nickname);

release.countDown();
byte[] id = bytes(java.util.UUID.fromString(read(result, "$.data.user.id")));
Map<String, Object> row = Map.of();
for (int i = 0; i < 100 && !"APPROVED".equals(row.get("nickname_status")); i++) {
Thread.sleep(100);
row = jdbc.queryForMap("SELECT nickname_status,approved_nickname FROM users WHERE id=?", (Object) id);
}
assertThat(row.get("nickname_status")).isEqualTo("APPROVED");
assertThat(row.get("approved_nickname")).isEqualTo(nickname);
}

@Test void changedNicknameDiscardsStaleReviewWithoutHoldingTheUserLock() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ void concurrent_signup_converges_to_login() throws Exception {
assertThat(first.getResponse().getStatus()).isEqualTo(200);
String userId = read(first, "$.data.user.id");
long afterFirst = userRepository.count();
awaitModeration(); // 뒤에서 도는 심사가 스파이를 건드리는 중에 스텁을 바꾸지 않는다

// B 는 A 의 INSERT 직전에 조회를 마친 상태 — 경합 창을 재현한다(첫 조회만 "없음").
// (JPA 리포지토리는 인터페이스라 스파이가 실제 메서드를 호출할 수 없어, 이후 조회 결과는
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.web.client.RestClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
Expand All @@ -18,12 +19,13 @@
class SafeSearchClientTest {

private static final byte[] IMAGE = {1, 2, 3};
private static final String URL = "https://vision.googleapis.com/v1/images:annotate?key=test-key";
private static final String URL = "https://vision.googleapis.com/v1/images:annotate";

private static ModerationResult checkWith(String json) {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo(URL)).andExpect(method(HttpMethod.POST))
.andExpect(header("x-goog-api-key", "test-key"))
.andRespond(withSuccess(json, MediaType.APPLICATION_JSON));
ModerationResult result = new SafeSearchClient("test-key", builder.build()).check(IMAGE);
server.verify();
Expand Down
Loading
Loading