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,44 @@
package com.polywave.billservice.api.controller.internal;

import com.polywave.billservice.application.user.command.service.UserBillDataCommandService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* user-service 회원 탈퇴 시 호출하는 사용자 파생 데이터 삭제 internal API.
*
* - InternalApiKeyFilter 가 X-Internal-Api-Key 헤더로 가드한다.
* - 북마크/관심사/조회 기록만 삭제하고 표결(user_bill_votes)은 보존한다. 멱등.
*/
@Tag(name = "Internal User Bill Data", description = "[Internal] 회원 탈퇴 시 사용자 의안 파생 데이터 삭제")
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/users")
public class InternalUserBillDataController {

private final UserBillDataCommandService userBillDataCommandService;

@Operation(summary = "[Internal] 사용자 의안 파생 데이터 삭제(북마크/관심사/조회)", description = """
회원 탈퇴 시 해당 사용자의 북마크/관심사/조회 기록을 삭제한다.
표결(투표) 기록은 표결 결과 보존을 위해 삭제하지 않는다. 멱등하게 동작한다.
""")
@DeleteMapping("/{userId}/bill-data")
public ResponseEntity<Void> deleteUserBillData(
@Parameter(in = ParameterIn.HEADER, name = "X-Internal-Api-Key", description = "서비스 간 internal 공유 키", required = true)
@RequestHeader(value = "X-Internal-Api-Key", required = false) String internalApiKey,

@Parameter(description = "탈퇴 사용자 ID") @PathVariable Long userId
) {
userBillDataCommandService.deleteAllByUser(userId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.polywave.billservice.application.user.command.service;

import com.polywave.billservice.repository.command.UserBillBookmarkCommandRepository;
import com.polywave.billservice.repository.command.UserBillInterestCommandRepository;
import com.polywave.billservice.repository.command.UserBillViewCommandRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 회원 탈퇴 시 사용자의 의안 파생 데이터(북마크/관심사/조회 기록)를 일괄 삭제한다.
*
* <p>표결(user_bill_votes)은 표결 결과 보존을 위해 삭제하지 않는다.
* (사고 방지를 위해 vote repository 는 의도적으로 주입하지 않는다.)
* 멱등하게 동작한다 — 데이터가 없어도 0건 삭제로 정상 종료한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserBillDataCommandService {

private final UserBillBookmarkCommandRepository userBillBookmarkCommandRepository;
private final UserBillInterestCommandRepository userBillInterestCommandRepository;
private final UserBillViewCommandRepository userBillViewCommandRepository;

@Transactional
public void deleteAllByUser(Long userId) {
int bookmarks = userBillBookmarkCommandRepository.deleteAllByUserId(userId);
int interests = userBillInterestCommandRepository.deleteAllByUserId(userId);
int views = userBillViewCommandRepository.deleteAllByUserId(userId);

log.info("[WITHDRAW] 사용자 의안 데이터 삭제 userId={} bookmarks={} interests={} views={} (votes 보존)",
userId, bookmarks, interests, views);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

import com.polywave.billservice.domain.UserBillBookmark;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserBillBookmarkCommandRepository extends JpaRepository<UserBillBookmark, Long> {

void deleteByUserIdAndBill_Id(Long userId, Long billId);

// 회원 탈퇴 시 해당 사용자의 북마크 전체 삭제 (멱등: 0건이어도 정상).
@Modifying
@Query("DELETE FROM UserBillBookmark b WHERE b.userId = :userId")
int deleteAllByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.polywave.billservice.domain.UserBillInterest;
import java.util.Set;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserBillInterestCommandRepository extends JpaRepository<UserBillInterest, Long> {

Expand All @@ -11,4 +14,9 @@ public interface UserBillInterestCommandRepository extends JpaRepository<UserBil
* 파생 쿼리 메서드는 category.id 형태로 "category_Id" 를 사용해야 합니다.
*/
void deleteByUserIdAndCategory_IdIn(Long userId, Set<Long> categoryIds);

// 회원 탈퇴 시 해당 사용자의 관심사 전체 삭제 (멱등).
@Modifying
@Query("DELETE FROM UserBillInterest i WHERE i.userId = :userId")
int deleteAllByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ ON CONFLICT (user_id, bill_id)
WHERE user_bill_views.last_viewed_at <= now() - INTERVAL '24 hours'
""", nativeQuery = true)
int upsertIfViewCountable(@Param("userId") Long userId, @Param("billId") Long billId);

// 회원 탈퇴 시 해당 사용자의 조회 기록 전체 삭제 (멱등).
@Modifying
@Query("DELETE FROM UserBillView v WHERE v.userId = :userId")
int deleteAllByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@
import org.springframework.web.filter.OncePerRequestFilter;

/**
* /internal/segments/** 경로에 대한 서비스 간 internal API 키 가드.
* /internal/** 경로에 대한 서비스 간 internal API 키 가드.
*
* - X-Internal-Api-Key 헤더가 설정값(bill-service.internal-api.key)과 일치할 때만 통과.
* - 키가 설정되지 않은 환경에서는 fail-closed: 무조건 403.
* - 사용자 JWT 와는 별도 채널. notification-service 스케줄러처럼 사용자 컨텍스트가 없는 호출 전용.
* - 사용자 JWT 와는 별도 채널. notification-service 스케줄러나 user-service 탈퇴처럼
* 사용자 컨텍스트가 없는 서버-서버 호출 전용.
*/
@Component
public class InternalApiKeyFilter extends OncePerRequestFilter {

static final String HEADER_NAME = "X-Internal-Api-Key";
static final String PROTECTED_PATTERN = "/internal/segments/**";
static final String[] PROTECTED_PATTERNS = {
"/internal/segments/**", // notification-service 세그먼트 조회
"/internal/users/**" // user-service 회원 탈퇴 시 파생 데이터 삭제
};

private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final String configuredKey;
Expand All @@ -41,7 +45,7 @@ protected void doFilterInternal(

// getServletPath() 는 context-path 가 stripped 된 경로를 반환한다.
// (getRequestURI() 는 context-path 를 포함하므로 PROTECTED_PATTERN 과 매칭되지 않아 가드가 무력화된다)
if (!pathMatcher.match(PROTECTED_PATTERN, request.getServletPath())) {
if (!isProtected(request.getServletPath())) {
filterChain.doFilter(request, response);
return;
}
Expand All @@ -60,6 +64,15 @@ protected void doFilterInternal(
filterChain.doFilter(request, response);
}

private boolean isProtected(String servletPath) {
for (String pattern : PROTECTED_PATTERNS) {
if (pathMatcher.match(pattern, servletPath)) {
return true;
}
}
return false;
}

private void writeForbidden(HttpServletResponse response, String code) throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ private SecurityEndpoints() {}

// 서비스 간 internal API: InternalApiKeyFilter 가 별도 가드.
"/internal/segments/**",
"/internal/users/**",

// Swagger (운영에서는 profile로 끄는 게 안전)
"/swagger-ui/**", "/v3/api-docs/**",
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ services:
build:
context: .
dockerfile: bill-service/Dockerfile
image: ghcr.io/poly-wave/mypoly-bill-service:0.6
image: ghcr.io/poly-wave/mypoly-bill-service:0.1
container_name: mypoly-bill-service
restart: unless-stopped

Expand Down
9 changes: 8 additions & 1 deletion k8s/base/services/user-service/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ data:
GOOGLE_ALLOWED_AUDIENCES: "714490493102-d26i4c6magpkh6f4brhi2dvml3d0idbd.apps.googleusercontent.com,714490493102-82qd3ftt7lvcnbdau3j7f0ko25ndtmpo.apps.googleusercontent.com,714490493102-trj6kfjoceqhloru93snmhvvsoooa5lg.apps.googleusercontent.com"
APPLE_ALLOWED_AUDIENCES: "kr.kro.mypoly.app.dev,kr.kro.mypoly.app"

DB_URL: "jdbc:postgresql://mypoly-postgres:5432/mypoly"
DB_URL: "jdbc:postgresql://mypoly-postgres:5432/mypoly"

# 회원 탈퇴 시 타 서비스 사용자 데이터 삭제 호출 endpoint (k8s DNS 기반).
# bill-service 는 context-path(/bills)를 박고 있어 prefix 포함, notification-service 는 미사용.
# internal 호출 키(BILL_SERVICE_INTERNAL_API_KEY / NOTIFICATION_ADMIN_API_KEY)는
# mypoly-internal-api-secret 에서 주입된다(이미 마운트됨).
BILL_SERVICE_URL: "http://bill-service/bills"
NOTIFICATION_SERVICE_URL: "http://notification-service"
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.polywave.notificationservice.api.controller.internal;

import com.polywave.notificationservice.api.spec.InternalUserNotificationApi;
import com.polywave.notificationservice.application.notification.user.command.service.UserNotificationCommandService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

/**
* user-service 회원 탈퇴 시 호출하는 사용자 알림 삭제 internal API.
* AdminApiKeyFilter 가 X-Admin-Api-Key 헤더로 가드한다.
*/
@RestController
@RequiredArgsConstructor
public class InternalUserNotificationController implements InternalUserNotificationApi {

private final UserNotificationCommandService userNotificationCommandService;

@Override
public ResponseEntity<Void> deleteUserNotifications(Long userId) {
userNotificationCommandService.deleteAllByUser(userId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.polywave.notificationservice.api.spec;

import com.polywave.common.dto.ErrorResponse;
import com.polywave.common.example.CommonApiExamples;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

@Tag(name = "Internal User Notification", description = """
[Internal] 회원 탈퇴 시 사용자 알림 삭제 API.

`X-Admin-Api-Key` 헤더가 서버 설정값과 일치해야 호출 가능합니다.
- 운영 배포 시 `NOTIFICATION_ADMIN_API_KEY` 환경변수로 주입
- 키가 설정되지 않은 환경에서는 무조건 403 (fail-closed)
""")
@SecurityRequirement(name = "adminApiKey")
@RequestMapping("/internal/user-notifications")
public interface InternalUserNotificationApi {

@Operation(summary = "[Internal] 사용자 알림 전체 삭제", description = """
회원 탈퇴 시 해당 사용자의 알림을 모두 물리 삭제한다. 멱등하게 동작한다(없어도 204).
""")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "삭제 성공"),
@ApiResponse(
responseCode = "500",
description = "서버 오류",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "서버 오류", value = CommonApiExamples.EXAMPLE_INTERNAL_SERVER_ERROR)
)
)
})
@DeleteMapping("/{userId}")
ResponseEntity<Void> deleteUserNotifications(
@Parameter(description = "탈퇴 사용자 ID") @PathVariable Long userId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ public int markAllAsRead(Long userId) {
return userNotificationCommandRepository.markAllAsRead(userId, Instant.now());
}

/** 회원 탈퇴 시 해당 사용자의 알림을 모두 삭제한다. 멱등(0건이어도 정상). */
public int deleteAllByUser(Long userId) {
int deleted = userNotificationCommandRepository.deleteAllByUserId(userId);
log.info("[WITHDRAW] 사용자 알림 삭제 userId={} deleted={}", userId, deleted);
return deleted;
}

/** 치환 후 문자열이 컬럼 한계를 넘으면 말줄임표(…) 를 붙여 자른다. */
private static String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ public interface UserNotificationCommandRepository extends JpaRepository<UserNot
AND n.deletedAt IS NULL
""")
int markAllAsRead(@Param("userId") Long userId, @Param("readAt") Instant readAt);

// 회원 탈퇴 시 해당 사용자의 알림을 물리 삭제한다(soft-delete 가 아닌 완전 삭제). 멱등.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM UserNotification n WHERE n.userId = :userId")
int deleteAllByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import org.springframework.web.filter.OncePerRequestFilter;

/**
* /internal/notification-policies/** 경로에 대한 어드민 API 키 가드.
* /internal/** 경로에 대한 어드민 API 키 가드.
*
* - X-Admin-Api-Key 헤더가 설정값(notification.admin-api.key)과 일치할 때만 통과.
* - 키가 설정되지 않은 환경에서는 fail-closed: 무조건 403.
Expand All @@ -23,7 +23,10 @@
public class AdminApiKeyFilter extends OncePerRequestFilter {

static final String HEADER_NAME = "X-Admin-Api-Key";
static final String PROTECTED_PATTERN = "/internal/notification-policies/**";
static final String[] PROTECTED_PATTERNS = {
"/internal/notification-policies/**", // 알림 정책 관리
"/internal/user-notifications/**" // user-service 회원 탈퇴 시 알림 삭제
};

private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final String configuredKey;
Expand All @@ -41,7 +44,7 @@ protected void doFilterInternal(

// getServletPath() 는 context-path 가 stripped 된 경로를 반환한다.
// (getRequestURI() 는 context-path 를 포함하므로 PROTECTED_PATTERN 과 매칭되지 않아 가드가 무력화된다)
if (!pathMatcher.match(PROTECTED_PATTERN, request.getServletPath())) {
if (!isProtected(request.getServletPath())) {
filterChain.doFilter(request, response);
return;
}
Expand All @@ -60,6 +63,15 @@ protected void doFilterInternal(
filterChain.doFilter(request, response);
}

private boolean isProtected(String servletPath) {
for (String pattern : PROTECTED_PATTERNS) {
if (pathMatcher.match(pattern, servletPath)) {
return true;
}
}
return false;
}

private void writeForbidden(HttpServletResponse response, String code) throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ private SecurityEndpoints() {
public static final String[] PUBLIC_ENDPOINTS = {
"/", "/error",

// 알림 정책 관리: AdminApiKeyFilter 가 별도 가드, Spring Security 단에서는 공개.
// 알림 정책 관리 / 회원 탈퇴 알림 삭제: AdminApiKeyFilter 가 별도 가드, Spring Security 단에서는 공개.
"/internal/notification-policies/**",
"/internal/user-notifications/**",

// Swagger (context-path 미사용 → 경로를 /notifications 아래로 명시)
"/notifications/swagger-ui/**", "/notifications/swagger-ui.html", "/notifications/v3/api-docs/**",
Expand Down
Loading