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/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..ec830c59 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 @@ -8,6 +8,8 @@ import com.whereyouad.WhereYouAd.domains.platform.application.dto.request.PlatformRequest; import com.whereyouad.WhereYouAd.domains.platform.application.dto.response.PlatformResponse; import com.whereyouad.WhereYouAd.domains.platform.application.mapper.PlatformConverter; +import com.whereyouad.WhereYouAd.domains.platform.domain.constant.PlatformStatus; +import com.whereyouad.WhereYouAd.domains.platform.domain.service.scheduler.PlatformDataCleanupExecutor; 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; @@ -27,6 +29,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 +45,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; @@ -102,8 +106,10 @@ public PlatformResponse.PlatformAccountListResponse getPlatformSyncInfos(Long us throw new PlatformHandler(PlatformErrorCode.PLATFORM_FORBIDDEN); } - // userId, orgId 기반 PlatformConnection 모두 조회 - List connections = platformConnectionRepository.findByUserIdAndOrgId(userId, orgId); + // userId, orgId 기반 PlatformConnection 조회 (삭제 대기(DISCONNECTED) 계정은 이미 해제된 것으로 보고 제외) + List connections = platformConnectionRepository.findByUserIdAndOrgId(userId, orgId).stream() + .filter(connection -> connection.getPlatformAccount().getStatus() != PlatformStatus.DISCONNECTED) + .toList(); // DTO 로 변환 및 반환 return PlatformConverter.toPlatformAccountListResponse(connections); @@ -144,10 +150,75 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o return PlatformConverter.toPlatformAccountResponse(platformAccount); } + // 광고 플랫폼 연동 해제 (수동 요청) + // 권한/소유자 검증 후 상태만 DISCONNECTED 로 변경하고 즉시 반환, 실제 데이터 삭제는 PlatformAccountCleanupScheduler 에서 비동기 진행 @Override public void disconnectPlatform(Long userId, Long orgId, Long accountId) { - // TODO: 관련된 PlatformConnection, PlatformAccount, 광고 도메인 엔티티 제거 로직 개발 - return; + // 검증 로직 + 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)); + + // 멱등성: 이미 삭제 대기 상태면 그대로 반환 + if (platformAccount.getStatus() == PlatformStatus.DISCONNECTED) { + return; + } + + // 상태만 DISCONNECTED 로 변경 + platformAccount.softDelete(); + } + + // 시스템 내부 호출용 플랫폼 연동 해제 — 권한 검증 없이 계정 단위로 실제 데이터를 정리 + // 호출처: 회원 탈퇴 스케줄러(UserDeleteScheduler), 수동 연동 해제 정리 스케줄러(PlatformAccountCleanupScheduler) + @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 + 비어있는 Project 삭제 진행 + // PlatformAccount 가 가장 마지막에 삭제됨 & 삭제 실패 시 전체 롤백되어 다음 회차에 재시도 + platformDataCleanupExecutor.deleteAccountAndRelations(accountId, projectIds); } private void validateNaverCredentials(String customerId, String encryptedApiKey, String encryptedSecretKey) { diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformAccountCleanupScheduler.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformAccountCleanupScheduler.java new file mode 100644 index 00000000..8dd365c4 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformAccountCleanupScheduler.java @@ -0,0 +1,45 @@ +package com.whereyouad.WhereYouAd.domains.platform.domain.service.scheduler; + +import com.whereyouad.WhereYouAd.domains.platform.domain.constant.PlatformStatus; +import com.whereyouad.WhereYouAd.domains.platform.domain.service.PlatformService; +import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformAccountRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PlatformAccountCleanupScheduler { + + private final PlatformAccountRepository platformAccountRepository; + private final PlatformService platformService; + + // 수동 연동 해제(DISCONNECTED 마킹)된 PlatformAccount 의 실제 데이터 정리 + // cleanupAccount 는 ClickLog/MetricFact/AdCampaign/Connection 먼저 삭제, PlatformAccount는 맨 마지막에 삭제 + // TODO : 스케줄러 주기를 어떻게 할지? (현재는 새벽 4시에 실행) + @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul") + public void cleanupDeletedAccounts() { + List accountIds = platformAccountRepository.findIdsByStatus(PlatformStatus.DISCONNECTED); + if (accountIds.isEmpty()) { + return; + } + log.info("연동 해제(DISCONNECTED) 계정 정리 스케줄러 실행 - 대상 {}건", accountIds.size()); + + int successCount = 0; + for (Long accountId : accountIds) { + try { + platformService.disconnectAccountBySystem(accountId); + successCount++; + } catch (Exception e) { + // 한 계정의 실패가 나머지 계정 정리를 막지 않도록 격리 → 다음 회차에 재시도 + log.error("연동 해제 계정 정리 실패 - accountId={}", accountId, e); + } + } + + log.info("연동 해제(DISCONNECTED) 계정 정리 완료 - 대상 갯수: {}, 성공 갯수: {}", accountIds.size(), successCount); + } +} \ No newline at end of file diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java new file mode 100644 index 00000000..4bd4931d --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java @@ -0,0 +1,98 @@ +package com.whereyouad.WhereYouAd.domains.platform.domain.service.scheduler; + +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.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 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 PlatformAccountRepository platformAccountRepository; + private final PlatformConnectionRepository platformConnectionRepository; + private final AdCampaignRepository adCampaignRepository; + private final ProjectRepository projectRepository; + private final ClickLogRepository clickLogRepository; + private final MetricFactRepository metricFactRepository; + + // 삭제에 영향받는 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 + 비어있는 Project 원자적 삭제 + @Transactional + public void deleteAccountAndRelations(Long accountId, List projectIds) { + PlatformAccount platformAccount = platformAccountRepository.findById(accountId) + .orElse(null); + + if (platformAccount == null) { + log.info("이미 삭제된 PlatformAccount - accountId={}, 정리 skip", accountId); + return; + } + + // AdCampaign 제거 + List campaigns = adCampaignRepository.findByPlatformAccount(platformAccount); + if (!campaigns.isEmpty()) { + adCampaignRepository.deleteAll(campaigns); + adCampaignRepository.flush(); + // AdCampaign 삭제 시 CascadeType.ALL 로 인해 연관된 AdGroup, AdContent 도 함께 제거됨 + } + + // PlatformConnection 제거 + List connections = platformConnectionRepository.findAllByPlatformAccount_Id(accountId); + if (!connections.isEmpty()) { + platformConnectionRepository.deleteAll(connections); + platformConnectionRepository.flush(); + } + + // 빈 Project 제거 + for (Long projectId : projectIds) { + if (adCampaignRepository.countByProject_Id(projectId) == 0 && + metricFactRepository.countByProject_Id(projectId) == 0) + { + projectRepository.deleteById(projectId); + } + } + + // PlatformAccount 제거 + platformAccountRepository.delete(platformAccount); + } +} \ No newline at end of file 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/entity/PlatformAccount.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/entity/PlatformAccount.java index 9c2ae2a6..04640c53 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/entity/PlatformAccount.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/entity/PlatformAccount.java @@ -52,4 +52,9 @@ public class PlatformAccount extends BaseEntity { @JoinColumn(name = "org_id") private Organization organization; + // 수동 연동 해제 요청 시 상태만 DISCONNECTED 로 변경 (실제 데이터 정리는 스케줄러가 수행) + public void softDelete() { + this.status = PlatformStatus.DISCONNECTED; + } + } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformAccountRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformAccountRepository.java index 7394ab45..a4c6dd51 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformAccountRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformAccountRepository.java @@ -2,12 +2,21 @@ import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; +import com.whereyouad.WhereYouAd.domains.platform.domain.constant.PlatformStatus; import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformAccount; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface PlatformAccountRepository extends JpaRepository { + + // DISCONNECTED 인 PlatformAccount id 목록 조회 (연동 해제 정리 스케줄러용) + @Query("SELECT pa.id FROM PlatformAccount pa WHERE pa.status = :status") + List findIdsByStatus(@Param("status") PlatformStatus status); + Optional findByExternalAccountIdAndProviderAndOrganization_Id(String externalAccountId, Provider provider, Long orgId); // 계정이 존재하는지 확인 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..35ade7c5 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, @@ -72,7 +69,7 @@ public ResponseEntity> disconnectPlatform( { platformService.disconnectPlatform(userId, orgId, accountId); - return ResponseEntity.ok(DataResponse.from("광고 플랫폼 연동 정보와 연관된 광고 정보가 정상적으로 삭제되었습니다.")); + return ResponseEntity.ok(DataResponse.from("광고 플랫폼 연동 해제 요청이 접수되었습니다. 연관된 광고 데이터는 잠시 후 자동으로 정리됩니다.")); } } 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..a8c698b5 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,28 @@ ResponseEntity> updateNaverAdAcco @PathVariable Long orgId, @RequestBody PlatformRequest.PlatformAccount request ); + + @Operation( + summary = "광고 플랫폼 계정 연동 해제 API", + description = "본인이 등록한 광고 플랫폼 계정의 연동을 해제합니다. \n\n" + + "요청 시점에는 계정 상태만 '삭제 대기(DISCONNECTED)'로 변경하고 즉시 응답하며, " + + "해당 계정에 종속된 모든 광고 데이터(ClickLog/MetricFact/AdCampaign/Connection/빈 Project)는 " + + "스케줄러가 비동기로 정리합니다.\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..a989b972 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,28 @@ public void hardDeleteUsers() { int successCount = 0; for (Long userId : targetUserIds) { try { + // 광고 플랫폼 연동 자동 해제 (계정 + 연관 광고 엔티티/ClickLog/MetricFact/비어있는 Project 정리) + // PlatformAccount → Organization FK 위반 방지를 위해 조직 Hard Delete 전에 수행 + List accountIds = platformConnectionRepository.findDistinctAccountIdsByUserId(userId); + + boolean isAllCleaned = true; + for (Long accountId : accountIds) { + try { + platformService.disconnectAccountBySystem(accountId); + } catch (Exception e) { + // 광고계정 하나에서 실패가 나머지 계정/회원 삭제 진행을 막지 않도록 격리 -> 다음 광고 게정 계속 + isAllCleaned = false; + log.error("플랫폼 계정 정리 실패 - userId={}, accountId={}", userId, accountId, e); + } + } + + // 삭제 실패한 계정이 하나라도 남아있으면 Organization Hard Delete 시 FK 위반 -> 이번 회차에선 보류, 다음 회차에 재시도 + if (!isAllCleaned) { + log.warn("플랫폼 계정 정리 미완료이므로 Hard Delete 보류 - userId={}", userId); + continue; + } + + // 회원/조직/멤버 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", "이메일 인증이 진행되지 않았습니다."), diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java index 6b5fcddf..93ccdf14 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/docs/UserControllerDocs.java @@ -127,7 +127,6 @@ public ResponseEntity> modifyUserInfo( description = "AccessToken 을 헤더로 받아 현재 로그인한 회원을 탈퇴 처리합니다. " + "Soft Delete 방식이며, 30일 유예 기간 이후 스케줄러가 Hard Delete 를 수행합니다.\n\n" + "### 1. 사전 검증 (통과해야 탈퇴 가능)\n" + - "- **광고 플랫폼 연동(PlatformConnection) 존재 여부**: 연동된 광고 플랫폼이 하나라도 남아 있으면 탈퇴가 차단됩니다. 먼저 모든 광고 플랫폼 연동을 해제한 뒤 다시 시도해야 합니다. -> 추후 삭제 API 추가 예정\n\n" + "- **본인이 소유자인 조직에 다른 ACTIVE 멤버 존재**: 본인이 생성자(owner)인 조직에 본인 외 다른 ACTIVE 멤버가 남아 있으면 탈퇴가 차단됩니다. `PATCH /api/org/{orgId}/changeOwner` API 로 소유권을 위임한 뒤 재시도해야 합니다.\n\n" + "### 2. 회원이 속한 워크스페이스(Organization) 처리\n" + "- **단순 ADMIN / MEMBER 로만 속해있는 조직** → 탈퇴 시점에는 별도 처리 없이 유지됩니다. 이후 Hard Delete 단계에서 일괄 삭제됩니다.\n\n" + @@ -140,13 +139,13 @@ public ResponseEntity> modifyUserInfo( "- deletedAt 으로부터 30일 경과한 Soft Deleted 회원이 대상입니다.\n\n" + "- 회원이 owner 인 Soft Deleted 조직과 관련 데이터(워크스페이스 소속 정보 / 활동 타임라인 / AI 인사이트 리포트 / 보낸 초대장 / 조직 로고 S3 이미지) Hard Delete.\n\n" + "- 회원이 속한 모든 워크스페이스에서 회원이 제외됨.\n\n" + + "- 회원이 연동한 모든 광고 플랫폼 연동 정보 Hard Delete + 연관된 광고정보 모두 Hard Delete\n\n" + "- 회원 본인 Hard Delete 후 프로필 이미지 S3 삭제. S3 이미지 삭제 실패는 서버 로그로만 기록됩니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "404_1", description = "USER_404_1 : 해당 사용자 존재하지 않음"), - @ApiResponse(responseCode = "400_9", description = "USER_400_9 : 다른 멤버가 속한 조직의 소유자는 탈퇴할 수 없음 (소유권 위임 후 재시도 필요)"), - @ApiResponse(responseCode = "400_10", description = "USER_400_10 : 연동된 광고 플랫폼이 존재하여 탈퇴할 수 없음 (모든 연동 해제 후 재시도 필요)") + @ApiResponse(responseCode = "400_9", description = "USER_400_9 : 다른 멤버가 속한 조직의 소유자는 탈퇴할 수 없음 (소유권 위임 후 재시도 필요)") }) public ResponseEntity> deleteUser( @AuthenticationPrincipal(expression = "userId") Long userId