diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/AdCampaignRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/AdCampaignRepository.java index 1313bd2c..90c33bd9 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/AdCampaignRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/AdCampaignRepository.java @@ -69,4 +69,15 @@ Long sumBudgetsByUserIdAndOrgIdAndProvider(@Param("userId") Long userId, @Param( Optional findByExternalCampaignIdAndPlatformAccount(String externalCampaignId, PlatformAccount platformAccount); Optional findByPlatformAccountAndExternalCampaignId(PlatformAccount platformAccount, String externalCampaignId); + + // PlatformAccount 연동 해제 시 사용 + List findByPlatformAccount(PlatformAccount platformAccount); + + // 연동 해제 후 빈 Project 정리 대상 식별용 (project null 인 AdCampaign 은 제외) + @Query("SELECT DISTINCT c.project.id FROM AdCampaign c " + + "WHERE c.platformAccount.id = :platformAccountId AND c.project IS NOT NULL") + List findDistinctProjectIdsByPlatformAccountId(@Param("platformAccountId") Long platformAccountId); + + // 특정 Project 에 남아있는 AdCampaign 수 (빈 Project 판단용) + long countByProject_Id(Long projectId); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java index a4d18310..95fe1130 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java @@ -10,6 +10,7 @@ import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.RoasProjection; import com.whereyouad.WhereYouAd.domains.project.application.dto.ProjectQueryDto; 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; @@ -227,4 +228,17 @@ Optional findByAdContentAndTimeBucketAndGrain( ); Optional findByPlatformAccount_IdAndAdContent_IdAndTimeBucket(Long platformAccountId, Long adContentId, LocalDateTime timeBucket); + + // PlatformAccount 연동 해제 시 청크 단위 정리용 + @Modifying + @Query(value = "DELETE FROM metric_fact " + + "WHERE platform_account_id = :platformAccountId " + + "LIMIT :batchSize", + nativeQuery = true) + int deleteByPlatformAccountIdInBatch( + @Param("platformAccountId") Long platformAccountId, + @Param("batchSize") int batchSize + ); + + long countByProject_Id(Long projectId); } \ No newline at end of file diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickLogRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickLogRepository.java index 298396f9..a6144e7a 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickLogRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickLogRepository.java @@ -2,6 +2,26 @@ import com.whereyouad.WhereYouAd.domains.click.persistence.entity.ClickLog; 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 ClickLogRepository extends JpaRepository { -} + + // PlatformAccount 연동 해제 시 청크 단위 정리용 + // ad_content_id → ad_group_id → ad_campaign_id → platform_account_id 경유 + @Modifying + @Query(value = "DELETE FROM click_log " + + "WHERE ad_content_id IN (" + + " SELECT ac.ad_content_id FROM ad_content ac " + + " JOIN ad_group ag ON ac.ad_group_id = ag.ad_group_id " + + " JOIN ad_campaign camp ON ag.ad_campaign_id = camp.ad_campaign_id " + + " WHERE camp.platform_account_id = :platformAccountId" + + ") " + + "LIMIT :batchSize", + nativeQuery = true) + int deleteByPlatformAccountIdInBatch( + @Param("platformAccountId") Long platformAccountId, + @Param("batchSize") int batchSize + ); +} \ No newline at end of file diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgService.java index c4b1546a..a5c883b1 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgService.java @@ -24,6 +24,9 @@ public interface OrgService { void removeOrganizationSoft(Long userId, Long orgId); + // 회원 탈퇴 전용 Soft Delete - 플랫폼 연동 검증 생략 (연동 해제는 UserDeleteScheduler 가 Hard Delete 시점에 처리) + void removeOrganizationSoftForWithdrawal(Long orgId); + // User Hard Delete 정리용 - 특정 User 가 owner 인 Soft Deleted Organization 들을 관련 엔티티와 함께 Hard Delete void removeOrganizationsOwnedBySoftDeletedUser(Long userId); diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java index 977ba067..224882fc 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java @@ -326,6 +326,26 @@ public void removeOrganizationSoft(Long userId, Long orgId) { organization.softDelete(); } + // 회원 탈퇴 흐름 전용 Soft Delete + // UserService.deleteUser 내부에서 본인 단독 소유 조직을 정리할 때 사용 + // 호출 시점에 소유자 검증은 호출부(handleOrganizationsOwnedByUser)에서 이미 완료 + @Override + public void removeOrganizationSoftForWithdrawal(Long orgId) { + Organization organization = orgRepository.findById(orgId) + .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND)); + + // 현재 워크스페이스가 삭제되는 조직인 멤버들의 currentOrgId를 null로 초기화 + List orgMembers = orgMemberRepository.findOrgMemberByOrg(organization); + for (OrgMember member : orgMembers) { + if (Objects.equals(member.getUser().getCurrentOrgId(), orgId)) { + member.getUser().setCurrentOrgId(null); + } + } + + // 조직 status 만 DELETED 로 변경 + organization.softDelete(); + } + // User Hard Delete 정리용 - 해당 User 가 owner 인 Soft Deleted Organization 들을 Hard Delete // (Soft Delete 시 'owner + 다른 멤버 존재' 케이스는 차단되므로, 여기서는 본인 1명만 속한 조직만 존재) @Override diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java new file mode 100644 index 00000000..7169dd5f --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java @@ -0,0 +1,128 @@ +package com.whereyouad.WhereYouAd.domains.platform.domain.service; + +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; +import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickLogRepository; +import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgRole; +import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.OrgMember; +import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository; +import com.whereyouad.WhereYouAd.domains.platform.exception.PlatformHandler; +import com.whereyouad.WhereYouAd.domains.platform.exception.code.PlatformErrorCode; +import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformAccount; +import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection; +import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformAccountRepository; +import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformConnectionRepository; +import com.whereyouad.WhereYouAd.domains.project.persistence.repository.ProjectRepository; +import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode; +import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler; +import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PlatformDataCleanupExecutor { + + private static final int BATCH_SIZE = 1000; + + private final UserRepository userRepository; + private final OrgMemberRepository orgMemberRepository; + private final PlatformAccountRepository platformAccountRepository; + private final PlatformConnectionRepository platformConnectionRepository; + private final AdCampaignRepository adCampaignRepository; + private final ProjectRepository projectRepository; + private final ClickLogRepository clickLogRepository; + private final MetricFactRepository metricFactRepository; + + // 권한 & 소유자 검증 + 삭제에 영향받는 projectId 반환 (read-only & 짧은 트랜잭션) + @Transactional(readOnly = true) + public List verifyAndCollectProjectIds(Long userId, Long orgId, Long accountId) { + userRepository.findById(userId) + .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND)); + + OrgMember orgMember = orgMemberRepository.findByUserIdAndOrgId(userId, orgId) + .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ORG_MEMBER_NOT_FOUND)); + + if (orgMember.getRole() != OrgRole.ADMIN) { + throw new PlatformHandler(PlatformErrorCode.PLATFORM_FORBIDDEN); + } + + PlatformAccount platformAccount = platformAccountRepository.findById(accountId) + .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND)); + + if (!platformAccount.getOrganization().getId().equals(orgId)) { + throw new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_BELONG_TO_ORG); + } + + platformConnectionRepository.findByUserIdAndPlatformAccountId(userId, accountId) + .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_NOT_ACCOUNT_OWNER)); + + return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); + } + + // 요청자 권한 검증 없이 삭제에 영향받는 projectId 만 수집 (회원 탈퇴 스케줄러 등 시스템 내부 호출용) + @Transactional(readOnly = true) + public List collectProjectIds(Long accountId) { + return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); + } + + // 청크 단위로 ClickLog 삭제 — 메인 트랜잭션과 분리 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int deleteClickLogChunk(Long platformAccountId) { + int deleted = clickLogRepository.deleteByPlatformAccountIdInBatch(platformAccountId, BATCH_SIZE); + if (deleted > 0) { + log.debug("ClickLog 청크 삭제 - platformAccountId={}, deleted={}", platformAccountId, deleted); + } + return deleted; + } + + // 청크 단위로 MetricFact 삭제 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int deleteMetricFactChunk(Long platformAccountId) { + int deleted = metricFactRepository.deleteByPlatformAccountIdInBatch(platformAccountId, BATCH_SIZE); + if (deleted > 0) { + log.debug("MetricFact 청크 삭제 - platformAccountId={}, deleted={}", platformAccountId, deleted); + } + return deleted; + } + + // AdCampaign + PlatformConnection + PlatformAccount 원자적 삭제 + @Transactional + public void deleteAccountAndRelations(Long accountId) { + PlatformAccount platformAccount = platformAccountRepository.findById(accountId) + .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND)); + + List campaigns = adCampaignRepository.findByPlatformAccount(platformAccount); + + if (!campaigns.isEmpty()) { + adCampaignRepository.deleteAll(campaigns); + adCampaignRepository.flush(); + //AdCampaign 삭제 시 CascadeType.ALL 로 인해 연관된 AdGroup, AdContent 도 함꼐 제거됨 + } + + List connections = platformConnectionRepository.findAllByPlatformAccount_Id(accountId); + + if (!connections.isEmpty()) { + platformConnectionRepository.deleteAll(connections); + platformConnectionRepository.flush(); + } + + platformAccountRepository.delete(platformAccount); + } + + //빈 Project 1개 삭제 + @Transactional + public void deleteEmptyProject(Long projectId) { + // 연관된 AdCampaign, MetricFact 가 없을 경우에만 Project 삭제 진행 + if (adCampaignRepository.countByProject_Id(projectId) == 0 && metricFactRepository.countByProject_Id(projectId) == 0) { + projectRepository.deleteById(projectId); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformService.java index 4035b370..c4a82502 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformService.java @@ -12,4 +12,6 @@ public interface PlatformService { void disconnectPlatform(Long userId, Long orgId, Long accountId); + // 회원 탈퇴 스케줄러 등 시스템 내부 호출용 - 요청자 권한 검증 없이 계정 단위 연동 해제 + void disconnectAccountBySystem(Long accountId); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java index 837f835b..c5260d28 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -42,6 +43,7 @@ public class PlatformServiceImpl implements PlatformService { private final OrgMemberRepository orgMemberRepository; private final PlatformAccountRepository platformAccountRepository; private final PlatformConnectionRepository platformConnectionRepository; + private final PlatformDataCleanupExecutor platformDataCleanupExecutor; private final NaverClient naverClient; private final NaverAdAuthStrategy naverAdAuthStrategy; @@ -144,10 +146,55 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o return PlatformConverter.toPlatformAccountResponse(platformAccount); } + // 광고 플랫폼 연동 해제 + // 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리 @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void disconnectPlatform(Long userId, Long orgId, Long accountId) { - // TODO: 관련된 PlatformConnection, PlatformAccount, 광고 도메인 엔티티 제거 로직 개발 - return; + // 권한 검증 & 영향받는 projectId 수집 (짧은 read-only 트랜잭션) + List projectIds = platformDataCleanupExecutor.verifyAndCollectProjectIds(userId, orgId, accountId); + cleanupAccount(accountId, projectIds); + } + + // 회원 탈퇴 스케줄러 등 시스템 내부 호출용 플랫폼 연동 해제 + // 권한 검증 없이 계정 단위 정리 + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void disconnectAccountBySystem(Long accountId) { + List projectIds = platformDataCleanupExecutor.collectProjectIds(accountId); + cleanupAccount(accountId, projectIds); + } + + // 계정 단위 데이터 정리 메서드화 + // ClickLog / MetricFact 청크 삭제 → AdCampaign + PlatformConnection + PlatformAccount 삭제 → 빈 Project 삭제 + // 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리 + private void cleanupAccount(Long accountId, List projectIds) { + int chunkDeleted; // 하나의 청크 당 삭제 갯수 + long totalClickLogDeleted = 0L; // ClickLog 전체 삭제 갯수 + long totalMetricFactDeleted = 0L; // MetricFact 전체 삭제 갯수 + + // ClickLog 청크 정리 (REQUIRES_NEW) + do { + chunkDeleted = platformDataCleanupExecutor.deleteClickLogChunk(accountId); + totalClickLogDeleted += chunkDeleted; + } while (chunkDeleted > 0); + log.info("ClickLog 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalClickLogDeleted); + + // MetricFact 청크 정리 (REQUIRES_NEW) — Project 삭제 단계에서 FK 위반 방지 + do { + chunkDeleted = platformDataCleanupExecutor.deleteMetricFactChunk(accountId); + totalMetricFactDeleted += chunkDeleted; + } while (chunkDeleted > 0); + log.info("MetricFact 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalMetricFactDeleted); + + // AdCampaign + PlatformConnection + PlatformAccount 삭제 진행 + platformDataCleanupExecutor.deleteAccountAndRelations(accountId); + + // 비어있는 Project 엔티티 삭제 + // -> AdCampaign, AdGroup, AdContent 삭제로 인해 연관된 광고 객체가 없는 Project 엔티티 삭제 + for (Long projectId : projectIds) { + platformDataCleanupExecutor.deleteEmptyProject(projectId); + } } private void validateNaverCredentials(String customerId, String encryptedApiKey, String encryptedSecretKey) { diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/exception/code/PlatformErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/exception/code/PlatformErrorCode.java index 8df23563..9b7b3b6f 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/exception/code/PlatformErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/exception/code/PlatformErrorCode.java @@ -14,6 +14,8 @@ public enum PlatformErrorCode implements BaseErrorCode { // 403 PLATFORM_FORBIDDEN(HttpStatus.FORBIDDEN, "PLATFORM_403_1", "API키 등록은 ADMIN 권한이 필요합니다."), + PLATFORM_ACCOUNT_NOT_BELONG_TO_ORG(HttpStatus.FORBIDDEN, "PLATFORM_403_2", "해당 광고 계정은 요청한 조직 소속이 아닙니다."), + PLATFORM_NOT_ACCOUNT_OWNER(HttpStatus.FORBIDDEN, "PLATFORM_403_3", "본인이 등록한 광고 계정만 연동 해제할 수 있습니다."), // 404 PLATFORM_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "PLATFORM_404_1", "조직과 연결된 인증 정보를 찾을 수 없습니다."), diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformConnectionRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformConnectionRepository.java index c58e3004..7c8ff59f 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformConnectionRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformConnectionRepository.java @@ -24,9 +24,6 @@ public interface PlatformConnectionRepository extends JpaRepository findByPlatformAccount_Organization_IdAndPlatformAccount_Provider(Long orgId, Provider provider); - // 사용자 ID로 등록된 연동 정보 목록 조회 (회원 탈퇴 시 정리용) - List findByUser_Id(Long userId); - // 사용자 ID와 플랫폼으로 등록된 연동 정보 목록 조회 List findByUser_IdAndPlatformAccount_Provider(Long userId, Provider provider); @@ -43,4 +40,11 @@ public interface PlatformConnectionRepository extends JpaRepository findByUserIdAndOrgId(@Param("userId") Long userId, @Param("orgId") Long orgId); + + // PlatformAccount 연동 해제 시 해당 계정에 연결된 모든 connection 일괄 삭제를 위한 List 조회 + List findAllByPlatformAccount_Id(Long platformAccountId); + + // 회원 Hard Delete 시 해당 유저가 연동한 PlatformAccount id 목록 조회 (시스템 자동 연동 해제용) + @Query("SELECT DISTINCT c.platformAccount.id FROM PlatformConnection c WHERE c.user.id = :userId") + List findDistinctAccountIdsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/PlatformController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/PlatformController.java index d223a633..da1073f7 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/PlatformController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/PlatformController.java @@ -5,7 +5,6 @@ import com.whereyouad.WhereYouAd.domains.platform.domain.service.PlatformService; import com.whereyouad.WhereYouAd.domains.platform.presentation.docs.PlatformControllerDocs; import com.whereyouad.WhereYouAd.global.response.DataResponse; -import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -61,8 +60,6 @@ public ResponseEntity> updateNave ); } - - @Hidden @DeleteMapping("/{orgId}/accounts/{accountId}") public ResponseEntity> disconnectPlatform( @AuthenticationPrincipal(expression = "userId") Long userId, diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/docs/PlatformControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/docs/PlatformControllerDocs.java index cb911746..3d512248 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/docs/PlatformControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/presentation/docs/PlatformControllerDocs.java @@ -66,4 +66,25 @@ ResponseEntity> updateNaverAdAcco @PathVariable Long orgId, @RequestBody PlatformRequest.PlatformAccount request ); + + @Operation( + summary = "광고 플랫폼 계정 연동 해제 API", + description = "본인이 등록한 광고 플랫폼 계정의 연동을 해제하고, 해당 계정에 종속된 모든 광고 데이터를 정리합니다. \n\n" + + "**주의** : 삭제한 플랫폼 계정에 관련된 모든 광고 데이터가 삭제되며, 복구 불가합니다. 사용자에게 안내 필요\n\n" + + "ADMIN 권한을 가진 조직 멤버 중 본인이 직접 등록한(광고 플랫폼 연동을 진행한 회원 본인만) 계정만 해제할 수 있습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "연동 해제 및 관련 광고 데이터 정리 성공"), + @ApiResponse(responseCode = "403_1", description = "PLATFORM_403_1 - ADMIN 권한 없음"), + @ApiResponse(responseCode = "403_2", description = "PLATFORM_403_2 - 해당 광고 계정이 요청한 조직 소속이 아님"), + @ApiResponse(responseCode = "403_3", description = "PLATFORM_403_3 - 본인이 등록한 광고 계정이 아님"), + @ApiResponse(responseCode = "404_1", description = "USER_404_1 - 회원 정보를 찾을 수 없음"), + @ApiResponse(responseCode = "404_2", description = "PLATFORM_404_2 - 해당 조직의 멤버가 아님"), + @ApiResponse(responseCode = "404_3", description = "PLATFORM_404_3 - 해당 광고 계정을 찾을 수 없음") + }) + ResponseEntity> disconnectPlatform( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable(value = "orgId") Long orgId, + @PathVariable(value = "accountId") Long accountId + ); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java index 742bb1a7..62134a5b 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/UserService.java @@ -5,8 +5,6 @@ import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgInvitationRepository; import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository; -import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection; -import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformConnectionRepository; import com.whereyouad.WhereYouAd.domains.user.application.dto.request.UserInfoModifyRequest; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.MyOrgResponse; import com.whereyouad.WhereYouAd.domains.user.application.dto.response.MyPageResponse; @@ -43,7 +41,6 @@ public class UserService { private final OrgMemberRepository orgMemberRepository; private final OrgInvitationRepository orgInvitationRepository; private final RefreshTokenRepository refreshTokenRepository; - private final PlatformConnectionRepository platformConnectionRepository; private final OrgService orgService; private final PasswordEncoder passwordEncoder; private final RedisUtil redisUtil; @@ -217,9 +214,8 @@ public void deleteUser(Long userId) { .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND)); // 1) 검증 단계 - // PlatformConnection 이 연결되어있는가? -> 연결되어 있으면 오류 // 속한 Organization 중에 "해당 회원이 owner 인데 다른 회원이 멤버로 속한 Organization" 이 존재하는가? -> 존재 시 오류 - validateNoPlatformConnections(userId); + // 광고 플랫폼 연동 존재 여부가 회원 탈퇴를 막지 않음 -> UserDeleteScheduler 가 Hard Delete 시점에 자동 해제 handleOrganizationsOwnedByUser(userId); // 2) 즉시 정리 단계 @@ -232,14 +228,6 @@ public void deleteUser(Long userId) { user.softDeleteUser(); } - // 광고 플랫폼 연동 정보 존재 시 탈퇴 불가 - private void validateNoPlatformConnections(Long userId) { - List platformConnections = platformConnectionRepository.findByUser_Id(userId); - if (!platformConnections.isEmpty()) { - throw new UserHandler(UserErrorCode.USER_HAS_PLATFORM_CONNECTION); - } - } - // 회원이 owner 인 조직 처리 - 다른 멤버가 있으면 탈퇴 거부, 본인만 있으면 Soft Delete private void handleOrganizationsOwnedByUser(Long userId) { List orgMembers = orgMemberRepository.findOrgMemberByUserId(userId); @@ -256,9 +244,9 @@ private void handleOrganizationsOwnedByUser(Long userId) { throw new UserHandler(UserErrorCode.USER_OWNS_ORGANIZATION); } - // 본인만 속한 조직은 OrgService 의 Soft Delete 로직 호출 + // 본인만 속한 조직은 OrgService 의 회원 탈퇴 전용 Soft Delete 로직 호출 // 추후 Scheduler 에서 해당 Organization 과 연관 엔티티 한번에 정리 - orgService.removeOrganizationSoft(userId, organization.getId()); + orgService.removeOrganizationSoftForWithdrawal(organization.getId()); } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/scheduler/UserDeleteScheduler.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/scheduler/UserDeleteScheduler.java index f4a8c374..58692279 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/scheduler/UserDeleteScheduler.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/scheduler/UserDeleteScheduler.java @@ -1,5 +1,7 @@ package com.whereyouad.WhereYouAd.domains.user.domain.service.scheduler; +import com.whereyouad.WhereYouAd.domains.platform.domain.service.PlatformService; +import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformConnectionRepository; import com.whereyouad.WhereYouAd.domains.user.domain.constant.UserStatus; import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -19,6 +21,8 @@ public class UserDeleteScheduler { private final UserRepository userRepository; private final UserDeleteExecutor userDeleteExecutor; + private final PlatformConnectionRepository platformConnectionRepository; + private final PlatformService platformService; // 매일 새벽 3시 @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") @@ -33,6 +37,14 @@ public void hardDeleteUsers() { int successCount = 0; for (Long userId : targetUserIds) { try { + // 광고 플랫폼 연동 자동 해제 (계정 + 연관 광고 엔티티/ClickLog/MetricFact/비어있는 Project 정리) + // PlatformAccount → Organization FK 위반 방지를 위해 조직 Hard Delete 전에 수행 + List accountIds = platformConnectionRepository.findDistinctAccountIdsByUserId(userId); + for (Long accountId : accountIds) { + platformService.disconnectAccountBySystem(accountId); + } + + // 회원/조직/멤버 Hard Delete userDeleteExecutor.hardDeleteSingleUser(userId); successCount++; } catch (Exception e) { diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java index a9508020..310d031b 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/exception/code/UserErrorCode.java @@ -18,7 +18,6 @@ public enum UserErrorCode implements BaseErrorCode { SOCIAL_USER_PASSWORD_CANNOT_MODIFY(HttpStatus.BAD_REQUEST, "USER_400_7", "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다."), USER_OLD_PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_8", "비밀번호 변경을 위해선 이전 비밀번호 입력이 필요합니다."), USER_OWNS_ORGANIZATION(HttpStatus.BAD_REQUEST, "USER_400_9", "다른 멤버가 속한 조직의 생성자는 탈퇴할 수 없습니다. 소유권을 위임한 뒤 다시 시도해 주세요."), - USER_HAS_PLATFORM_CONNECTION(HttpStatus.BAD_REQUEST, "USER_400_10", "연동된 광고 플랫폼을 먼저 연동 해제한 뒤 재시도 해주세요."), // 401 USER_EMAIL_NOT_VERIFIED(HttpStatus.UNAUTHORIZED, "USER_401_1", "이메일 인증이 진행되지 않았습니다."),