Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c11b63d
[#593] feat: 유저 삭제 Internal api 비즈니스 로직 구현
huncozyboy Sep 11, 2025
0e0714c
[#593] feat: UserFacade에 deleteUser 위임 메서드 추가
huncozyboy Sep 11, 2025
b73fe75
[#593] feat: internal apiKey 검증 포함, 유저 삭제 컨트롤러 구현
huncozyboy Sep 11, 2025
2994bc0
[#593] feat: 직관적인 URI Path로 변경
huncozyboy Sep 12, 2025
1999e3e
[#593] feat: default 유저 레코드 삭제 API whitelist로 추가
huncozyboy Sep 12, 2025
8bfe51a
[#593] refactor: validateInternalApiKey로 공통 메서드 분리
huncozyboy Sep 13, 2025
ad6931f
[Feat] #593 - 플그관련 default 유저 삭제 Internal api 구현 (#594)
huncozyboy Sep 13, 2025
44a5583
[#593] modify: UserOriginalController에 internal API 제거
huncozyboy Sep 13, 2025
9dd69b4
[#593] modify: UserInternal 컨트롤러 생성 및 internal API 엔드포인트 변경
huncozyboy Sep 13, 2025
7720535
[#593] modify: 변경된 internal API 엔드포인트 whitelist에 추가
huncozyboy Sep 13, 2025
2bc3c90
[#598] modify: 변경된 UserInternal 컨트롤러 별도 패키지로 분리
huncozyboy Sep 15, 2025
6d55acd
[MODIFY] #598 - 플그 관련 internal API 엔드포인트 수정 (#599)
huncozyboy Sep 15, 2025
4c6d67e
[#600] deploy: dev 이전 이미지 자동 삭제를 위한 workflow 스크립트 추가
jher235 Sep 17, 2025
4dee36a
[deploy] #600 - ECR 이전 이미지 자동 삭제를 위한 workflow 수정 (#601)
jher235 Sep 18, 2025
b7bf1fc
[#602] deploy: ecr 퍼블릭 레포지토리로 지정 및 region 추가
jher235 Sep 18, 2025
512ec34
[DEPLOY] #602 - ECR 퍼블릭 레포지토리로 지정 및 region 추가 (#603)
jher235 Sep 18, 2025
02b758c
[#604] deploy: ECR에서 dev 이미지 가져오는 스크립트 수정
jher235 Sep 18, 2025
ff0dfb4
[DEPLOY] #604 - ECR에서 dev 이미지 가져오는 스크립트 수정 (#605)
jher235 Sep 18, 2025
13806a4
[#606] fix(ErrorCode): platform errorcode 추가
hyerinhwang-sailin Sep 21, 2025
bf48e3b
[#606] fix(PlatformClient): platform 신규 internal api 추가, 인자 변경
hyerinhwang-sailin Sep 21, 2025
0951ba5
[#606] fix(PlatformUserIdsRequest): platform 신규 internal api request 추가
hyerinhwang-sailin Sep 21, 2025
b000732
[#606] fix(PlatformService): GET 호출 시 CSV로 단일 파라미터 전송, CSV 길이가 임계값을 넘…
hyerinhwang-sailin Sep 21, 2025
31d3e1f
[#606] fix(PokeHistoryRepository): @Modifying 명시
hyerinhwang-sailin Sep 21, 2025
6aa89bc
[#606] fix(PokeHistoryService): 불필요한 정렬 제거, getAllLatestPokeHistoryIn…
hyerinhwang-sailin Sep 21, 2025
63c1730
[#606] fix(PokeFacade): 상대 ID 세팅 버그 수정, 빈 리스트 안전화 및 외부 호출 가드, 자기 자신 찌…
hyerinhwang-sailin Sep 21, 2025
0528175
[#606] fix(SlackLoggerAspect): Long, User, UserDetails(username), Str…
hyerinhwang-sailin Sep 21, 2025
9288b18
[fix] #606 - 콕찌르기/Platform 연동 안정화 & Slack 로깅 예외 방지 (#607)
hyerinhwang-sailin Sep 21, 2025
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
9 changes: 9 additions & 0 deletions .github/workflows/app-cd-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ jobs:
run: |
aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin $ECR_HOST

- name: Delete previous ECR images
run: |
echo "Deleting all images from ECR repository: $ECR_APP_NAME"
aws ecr-public batch-delete-image \
--region us-east-1 \
--repository-name $ECR_APP_NAME \
--image-ids "$(aws ecr-public describe-images --region us-east-1 --repository-name $ECR_APP_NAME --query 'imageDetails[*].{imageDigest:imageDigest}' --output json)" \
|| echo "No images to delete or repository is empty"

- name: 🐳Docker Image Build & Push
run: |
docker build \
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
package org.sopt.app.application.platform;

import feign.HeaderMap;
import feign.Param;
import feign.Headers;
import feign.QueryMap;
import feign.RequestLine;
import org.sopt.app.application.platform.dto.PlatformUserInfoResponse;

import org.sopt.app.application.platform.dto.PlatformUserIdsRequest;
import org.sopt.app.application.platform.dto.PlatformUserInfoWrapper;
import org.springframework.cloud.openfeign.EnableFeignClients;

import java.util.Collection;
import java.util.List;
import java.util.Map;

@EnableFeignClients
public interface PlatformClient {

// 유저정보 GET (CSV 쿼리)
@RequestLine("GET /api/v1/users")
PlatformUserInfoWrapper getPlatformUserInfo(@HeaderMap final Map<String, String> headers,
@QueryMap Map<String, Collection<String>> queryMap);
@QueryMap Map<String, String> queryMap);

// 유저정보 조회 POST (JSON 바디)
@RequestLine("POST /api/v1/users")
@Headers("Content-Type: application/json")
PlatformUserInfoWrapper postPlatformUserInfo(@HeaderMap Map<String, String> headers,
PlatformUserIdsRequest body);
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
package org.sopt.app.application.platform;

import static org.sopt.app.application.playground.PlaygroundHeaderCreator.*;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.jsonwebtoken.ExpiredJwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;

import org.sopt.app.application.auth.dto.PlaygroundAuthTokenInfo;
import org.sopt.app.application.platform.dto.PlatformUserIdsRequest;
import org.sopt.app.application.platform.dto.PlatformUserInfoResponse;
import org.sopt.app.application.platform.dto.PlatformUserInfoWrapper;
import org.sopt.app.application.playground.dto.PlaygroundProfileInfo;
import org.sopt.app.common.exception.BadRequestException;
import org.sopt.app.common.exception.UnauthorizedException;
import org.sopt.app.common.response.ErrorCode;
import org.sopt.app.domain.enums.UserStatus;
import org.sopt.app.presentation.auth.AppAuthRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;

import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
Expand All @@ -42,18 +32,65 @@ public class PlatformService {
@Value("${sopt.current.generation}")
private Long currentGeneration;

// URL 길이 한도
private static final int URL_QUERY_LENGTH_THRESHOLD = 1200;

public PlatformUserInfoResponse getPlatformUserInfoResponse(Long userId) {
final Map<String, String> headers = createAuthorizationHeader();
final Map<String, Collection<String>> params = createQueryParams(Collections.singletonList(userId));
final Map<String, String> params = createQueryParams(Collections.singletonList(userId));
PlatformUserInfoWrapper platformUserInfoWrapper = platformClient.getPlatformUserInfo(headers, params);
return platformUserInfoWrapper.data().getFirst();
List<PlatformUserInfoResponse> data= platformUserInfoWrapper.data();
if (data == null || data.isEmpty()) {
throw new BadRequestException(ErrorCode.PLATFORM_USER_NOT_EXISTS);
}
return data.getFirst();
}

public List<PlatformUserInfoResponse> getPlatformUserInfosResponse(List<Long> userIds) {
final Map<String, String> headers = createAuthorizationHeader();
final Map<String, Collection<String>> params = createQueryParams(userIds);

// 중복 제거
userIds = userIds.stream().distinct().toList();
final Map<String, String> params = createQueryParams(userIds);

PlatformUserInfoWrapper platformUserInfoWrapper = platformClient.getPlatformUserInfo(headers, params);
return platformUserInfoWrapper.data();

List<PlatformUserInfoResponse> data= platformUserInfoWrapper.data();
if (data == null || data.isEmpty()) {
throw new BadRequestException(ErrorCode.PLATFORM_USER_NOT_EXISTS);
}
return data;
}

// 길이에 따라 GET/POST 자동 선택
public List<PlatformUserInfoResponse> getPlatformUserInfosResponseSmart(List<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
throw new BadRequestException(ErrorCode.INVALID_PARAMETER);
}
final Map<String, String> headers = createAuthorizationHeader();

// 중복 제거
userIds = userIds.stream().distinct().toList();

// CSV 만들고 길이 체크
final String csv = toCsv(userIds);
final boolean usePost = csv.length() > URL_QUERY_LENGTH_THRESHOLD;

PlatformUserInfoWrapper wrapper;
if (usePost) {
// POST with JSON body
wrapper = platformClient.postPlatformUserInfo(headers, new PlatformUserIdsRequest(userIds));
} else {
// GET with CSV query
final Map<String, String> params = Map.of("userIds", csv);
wrapper = platformClient.getPlatformUserInfo(headers, params);
}

List<PlatformUserInfoResponse> data = wrapper.data();
if (data == null || data.isEmpty()) {
throw new BadRequestException(ErrorCode.PLATFORM_USER_NOT_EXISTS);
}
return data;
}

public UserStatus getStatus(Long userId) {
Expand All @@ -66,17 +103,21 @@ private UserStatus getStatus(List<Long> generationList) {

private Map<String, String> createAuthorizationHeader() {
Map<String, String> headers = new HashMap<>();
headers.put("x-api-key", apiKey);
headers.put("x-service-name", serviceName);
headers.put("X-Api-Key", apiKey);
headers.put("X-Service-Name", serviceName);
return headers;
}

private Map<String, Collection<String>> createQueryParams(List<Long> userId) {
Map<String, Collection<String>> queryParams = new HashMap<>();
for (Long id : userId) {
queryParams.put("userIds", Collections.singletonList(id.toString()));
private Map<String, String> createQueryParams(List<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
throw new BadRequestException(ErrorCode.INVALID_PARAMETER);
}
return queryParams;
final String csv = toCsv(userIds);
return Collections.singletonMap("userIds", csv);
}

private String toCsv(List<Long> userIds) {
return userIds.stream().map(String::valueOf).collect(Collectors.joining(","));
}

public List<Long> getMemberGenerationList(Long userId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.sopt.app.application.platform.dto;

import java.util.List;

public record PlatformUserIdsRequest(List<Long> userIds) {}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
Expand All @@ -24,7 +25,6 @@ public List<PokeHistoryInfo> getAllOfPokeBetween(Long userId, Long friendId) {

return pokeHistoryList.stream()
.map(PokeHistoryInfo::from)
.sorted(Comparator.comparing(PokeHistoryInfo::getCreatedAt).reversed())
.toList();
}

Expand Down Expand Up @@ -54,6 +54,9 @@ public List<PokeHistory> getAllLatestPokeHistoryFromTo(Long pokerId, Long pokedI
}

public Page<PokeHistory> getAllLatestPokeHistoryIn(List<Long> targetHistoryIds, Pageable pageable) {
if (targetHistoryIds == null || targetHistoryIds.isEmpty()) {
return Page.empty(pageable);
}
return pokeHistoryRepository.findAllByIdIsInOrderByCreatedAtDesc(targetHistoryIds, pageable);
}

Expand All @@ -79,6 +82,7 @@ public List<PokeHistoryInfo> getAllPokeHistoryByUsers(Long userId, Long friendUs
return pokeHistories.stream().map(PokeHistoryInfo::from).toList();
}

@Transactional
@EventListener(UserWithdrawEvent.class)
public void handleUserWithdrawEvent(final UserWithdrawEvent event) {
pokeHistoryRepository.deleteAllByPokerIdInQuery(event.getUserId());
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/org/sopt/app/application/user/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,12 @@ public UserInfo createUser(Long userId) {

return new UserInfo(savedUser.getId());
}

@Transactional
public void deleteUser(Long userId) {
userRepository.findUserById(userId)
.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND));

userRepository.deleteById(userId);
}
}
4 changes: 4 additions & 0 deletions src/main/java/org/sopt/app/common/response/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public enum ErrorCode {
PLAYGROUND_PROFILE_NOT_EXISTS("플레이그라운드 프로필을 등록하지 않은 유저입니다.", HttpStatus.NOT_FOUND),
INVALID_PLAYGROUND_CARDINAL_INFO("플레이그라운드 활동 정보가 유효하지 않습니다.", HttpStatus.BAD_REQUEST),

// PLATFORM
PLATFORM_USER_NOT_EXISTS("플랫폼 유저 정보를 가져올 수 없습니다.", HttpStatus.NOT_FOUND),

// OPERATION
OPERATION_PROFILE_NOT_EXISTS("운영 서비스에 존재하지 않는 회원입니다.", HttpStatus.NOT_FOUND),

Expand Down Expand Up @@ -76,6 +79,7 @@ public enum ErrorCode {
POKE_HISTORY_NOT_FOUND("해당 찌르기 내역은 존재하지 않습니다.", HttpStatus.NOT_FOUND),
POKE_MESSAGE_TYPE_NOT_FOUND("해당 찌르기 메시지 타입은 존재하지 않습니다.", HttpStatus.NOT_FOUND),
POKE_MESSAGE_MUST_NOT_BE_NULL("찌르기 메시지 타입은 필수 값입니다.", HttpStatus.BAD_REQUEST),
SELF_POKE_NOT_ALLOWED("본인을 찌를 수 없습니다.", HttpStatus.BAD_REQUEST),
DUPLICATE_POKE("이미 찌르기를 보낸 친구입니다.", HttpStatus.CONFLICT),

// FRIEND
Expand Down
77 changes: 61 additions & 16 deletions src/main/java/org/sopt/app/common/response/SlackLoggerAspect.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.sopt.app.domain.entity.User;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

@Aspect
Expand All @@ -22,26 +23,70 @@ public class SlackLoggerAspect {
private final HttpServletRequest request;
private final SlackService slackService;

private Long getUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
val user = (User) authentication.getPrincipal();
return user.getId();
// 가능한 모든 케이스에서 userId를 안전하게 추출
private Long resolveUserIdSafely() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) return null;

Object principal = authentication.getPrincipal();

// 1) 애플리케이션에서 전역적으로 쓰는 Long
if (principal instanceof Long l) return l;

// 2) 도메인 User 엔티티
if (principal instanceof User u) return u.getId();

// 3) Spring Security UserDetails (username이 숫자 id일 수 있음)
if (principal instanceof UserDetails ud) {
return parseLongOrNull(ud.getUsername());
}

// 4) String (예: "anonymousUser" 또는 "123")
if (principal instanceof String s) {
Long parsed = parseLongOrNull(s);
if (parsed != null) return parsed; // 숫자면 사용
return null; // anonymous 등은 null 처리
}

// 5) JWT/기타 토큰: authentication.getName()이 종종 subject/username
return parseLongOrNull(authentication.getName());
} catch (Exception ex) {
log.debug("SlackLoggerAspect: failed to resolve user id: {}", ex.getMessage());
return null; // userId를 못 구해도 로깅은 계속 진행
}
}

private Long parseLongOrNull(String v) {
if (v == null) return null;
try { return Long.parseLong(v); }
catch (NumberFormatException e) { return null; }
}

@Before(value = "@annotation(org.sopt.app.common.response.SlackLogger)")
public void sendLogForError(final JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (args.length != 1) {
log.warn("Slack Logger Failed : Invalid Used");
return;
}
try {
Object[] args = joinPoint.getArgs();

// 이 애스펙트는 보통 ControllerAdvice의 핸들러(예외 1개 파라미터)를 대상으로 사용된다고 가정
if (args.length == 1 && args[0] instanceof Exception e) {
String requestUrl = request.getRequestURI();
String requestMethod = request.getMethod();

ExceptionWrapper exceptionWrapper = extractExceptionWrapper(e);
Long userId = resolveUserIdSafely();

if (args[0] instanceof Exception e) {
String requestUrl = request.getRequestURI();
String requestMethod = request.getMethod();
ExceptionWrapper exceptionWrapper = extractExceptionWrapper(e);
slackService.sendSlackMessage(
SlackMessageGenerator.generate(exceptionWrapper,getUserId(),requestMethod,requestUrl));
slackService.sendSlackMessage(
SlackMessageGenerator.generate(exceptionWrapper, userId, requestMethod, requestUrl)
);
} else {
// 잘못 붙은 경우에도 전체 플로우를 깨지 않도록 경고만 남김
log.warn("Slack Logger skipped: invalid usage (expects single Exception arg). method={}, args={}",
joinPoint.getSignature(), args.length);
}
} catch (Exception ex) {
// 로거 자체가 장애 유발하지 않도록 방어
log.error("Slack Logger failed: {}", ex.getMessage(), ex);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ public class WebSecurityConfig {
"/api/v2/firebase/**",
"/api/v2/notification/**",
"/api/v2/user/main",
"/api/v2/user/register",
"/internal/api/v1/members",
"/internal/api/v1/members/{memberId}",
"/api/v2/home/app-service",
"/api/v2/home/floating-button",
"/api/v2/home/review-form"
Expand Down
Loading