From 23f19a704a62150b2dbce20b102393071e849503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 22 May 2026 16:27:42 +0900 Subject: [PATCH 01/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20PlatformErrorCode=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/platform/exception/code/PlatformErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) 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", "조직과 연결된 인증 정보를 찾을 수 없습니다."), From 597823bb7bd51bddd0e48e602b5d2aeaddc68e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 22 May 2026 16:32:47 +0900 Subject: [PATCH 02/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Repository=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AdCampaignRepository.java | 11 ++++++++++ .../repository/MetricFactRepository.java | 12 ++++++++++ .../repository/ClickLogRepository.java | 22 ++++++++++++++++++- .../PlatformConnectionRepository.java | 6 +++++ 4 files changed, 50 insertions(+), 1 deletion(-) 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..d72e6af9 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,15 @@ 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 + ); } \ 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/platform/persistence/repository/PlatformConnectionRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/persistence/repository/PlatformConnectionRepository.java index 09eb1032..46c48af3 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 @@ -3,6 +3,7 @@ import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection; 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; import java.util.Optional; @@ -40,4 +41,9 @@ public interface PlatformConnectionRepository extends JpaRepository findByUserIdAndOrgId(@Param("userId") Long userId, @Param("orgId") Long orgId); + + // PlatformAccount 연동 해제 시 해당 계정에 연결된 모든 connection 일괄 정리 + @Modifying + @Query("DELETE FROM PlatformConnection pc WHERE pc.platformAccount.id = :platformAccountId") + void deleteByPlatformAccount_Id(@Param("platformAccountId") Long platformAccountId); } From 973f2b3f00e052f53fe1c0ec8954dc2584ec4304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 22 May 2026 16:33:43 +0900 Subject: [PATCH 03/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20Executor=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PlatformDataCleanupExecutor.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java 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..e5cc9341 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java @@ -0,0 +1,40 @@ +package com.whereyouad.WhereYouAd.domains.platform.domain.service; + +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; +import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickLogRepository; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PlatformDataCleanupExecutor { + + private static final int BATCH_SIZE = 1000; + + private final ClickLogRepository clickLogRepository; + private final MetricFactRepository metricFactRepository; + + // 청크 단위로 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; + } +} \ No newline at end of file From db323f28c14a91d8eab80ee47b04fd6c4d4d027a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 22 May 2026 16:33:58 +0900 Subject: [PATCH 04/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Service=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/PlatformService.java | 2 + .../domain/service/PlatformServiceImpl.java | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+) 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 a065b2e6..c009007b 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 @@ -9,4 +9,6 @@ public interface PlatformService { PlatformResponse.PlatformAccountListResponse getPlatformSyncInfos(Long userId, Long orgId); PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long orgId, PlatformRequest.PlatformAccount request); + + void disconnectPlatform(Long userId, Long orgId, 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 b8fa5160..32d9d951 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 @@ -5,6 +5,8 @@ import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository; 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; @@ -14,6 +16,7 @@ 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.entity.User; @@ -42,6 +45,9 @@ public class PlatformServiceImpl implements PlatformService { private final OrgMemberRepository orgMemberRepository; private final PlatformAccountRepository platformAccountRepository; private final PlatformConnectionRepository platformConnectionRepository; + private final AdCampaignRepository adCampaignRepository; + private final ProjectRepository projectRepository; + private final PlatformDataCleanupExecutor platformDataCleanupExecutor; private final NaverClient naverClient; private final NaverAdAuthStrategy naverAdAuthStrategy; @@ -144,6 +150,73 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o return PlatformConverter.toPlatformAccountResponse(platformAccount); } + @Override + public void disconnectPlatform(Long userId, Long orgId, Long accountId) { + // 권한 검증: 회원 + ADMIN 권한 + PlatformAccount 가 해당 조직 소속인지 확인 + 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); + } + + // Owner 검증: 본인이 등록한 PlatformAccount 만 disconnect 가능 + // PlatformConnection (user_id + platform_account_id) 존재 여부로 판별 + platformConnectionRepository.findByUserIdAndPlatformAccountId(userId, accountId) + .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_NOT_ACCOUNT_OWNER)); + + // 영향받는 Project ID 수집 (AdCampaign 삭제 전에 미리 확보 — 삭제 후엔 못 찾음) + List affectedProjectIds = adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); + + 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 삭제 (Cascade ALL → AdGroup → AdContent 자동 정리) + List campaigns = adCampaignRepository.findByPlatformAccount(platformAccount); + if (!campaigns.isEmpty()) { + adCampaignRepository.deleteAll(campaigns); + adCampaignRepository.flush(); + } + + // PlatformConnection 정리 + platformConnectionRepository.deleteByPlatformAccount_Id(accountId); + + // PlatformAccount 정리 + platformAccountRepository.delete(platformAccount); + + // AdCampaign 이 0이 된 Project 자동 정리 + for (Long projectId : affectedProjectIds) { + if (adCampaignRepository.countByProject_Id(projectId) == 0) { + projectRepository.deleteById(projectId); + } + } + } + private void validateNaverCredentials(String customerId, String encryptedApiKey, String encryptedSecretKey) { try { PlatformConnection tempConnection = PlatformConverter.toTempPlatformConnection(customerId, encryptedApiKey, encryptedSecretKey); From b25cdda70197d05cffa51ad5da159f812bda5387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 22 May 2026 16:34:26 +0900 Subject: [PATCH 05/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/PlatformController.java | 12 +++++++++++ .../docs/PlatformControllerDocs.java | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 05d36e45..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 @@ -60,4 +60,16 @@ public ResponseEntity> updateNave ); } + @DeleteMapping("/{orgId}/accounts/{accountId}") + public ResponseEntity> disconnectPlatform( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable(value = "orgId") Long orgId, + @PathVariable(value = "accountId") Long accountId + ) + { + platformService.disconnectPlatform(userId, orgId, accountId); + + 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..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 + ); } From 8e640aef50ddfee9d7d603f2691c9aa184943c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Sat, 23 May 2026 16:46:08 +0900 Subject: [PATCH 06/13] =?UTF-8?q?:sparkles:=20feat:=20PlatformConnection?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20FK=20=EC=9C=84=EB=B0=98=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/PlatformConnectionRepository.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 46c48af3..e62a9bdc 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 @@ -3,7 +3,6 @@ import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection; 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; import java.util.Optional; @@ -42,8 +41,6 @@ public interface PlatformConnectionRepository extends JpaRepository findByUserIdAndOrgId(@Param("userId") Long userId, @Param("orgId") Long orgId); - // PlatformAccount 연동 해제 시 해당 계정에 연결된 모든 connection 일괄 정리 - @Modifying - @Query("DELETE FROM PlatformConnection pc WHERE pc.platformAccount.id = :platformAccountId") - void deleteByPlatformAccount_Id(@Param("platformAccountId") Long platformAccountId); + // PlatformAccount 연동 해제 시 해당 계정에 연결된 모든 connection 일괄 삭제를 위한 List 조회 + List findAllByPlatformAccount_Id(Long platformAccountId); } From f5201e73f9cf22a34c864a1a85164e2e61ace075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Sat, 23 May 2026 16:47:25 +0900 Subject: [PATCH 07/13] =?UTF-8?q?:sparkles:=20feat:=20PlatformConnection?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20FK=20=EC=9C=84=EB=B0=98=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84=20&=20DB=20=ED=92=80=20=EA=B3=A0=EA=B0=88=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PlatformDataCleanupExecutor.java | 80 +++++++++++++++++++ .../domain/service/PlatformServiceImpl.java | 57 +++---------- 2 files changed, 90 insertions(+), 47 deletions(-) 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 index e5cc9341..26368f8b 100644 --- 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 @@ -1,13 +1,30 @@ 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 @@ -15,9 +32,41 @@ 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); + } + // 청크 단위로 ClickLog 삭제 — 메인 트랜잭션과 분리 @Transactional(propagation = Propagation.REQUIRES_NEW) public int deleteClickLogChunk(Long platformAccountId) { @@ -37,4 +86,35 @@ public int deleteMetricFactChunk(Long platformAccountId) { } 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(); + } + + 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) { + if (adCampaignRepository.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/PlatformServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java index 32d9d951..6fac790b 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 @@ -5,8 +5,6 @@ import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; -import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; -import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository; 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; @@ -16,7 +14,6 @@ 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.entity.User; @@ -30,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; @@ -45,8 +43,6 @@ public class PlatformServiceImpl implements PlatformService { private final OrgMemberRepository orgMemberRepository; private final PlatformAccountRepository platformAccountRepository; private final PlatformConnectionRepository platformConnectionRepository; - private final AdCampaignRepository adCampaignRepository; - private final ProjectRepository projectRepository; private final PlatformDataCleanupExecutor platformDataCleanupExecutor; private final NaverClient naverClient; private final NaverAdAuthStrategy naverAdAuthStrategy; @@ -150,33 +146,12 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o return PlatformConverter.toPlatformAccountResponse(platformAccount); } + // 광고 플랫폼 연동 해제 @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void disconnectPlatform(Long userId, Long orgId, Long accountId) { - // 권한 검증: 회원 + ADMIN 권한 + PlatformAccount 가 해당 조직 소속인지 확인 - 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); - } - - // Owner 검증: 본인이 등록한 PlatformAccount 만 disconnect 가능 - // PlatformConnection (user_id + platform_account_id) 존재 여부로 판별 - platformConnectionRepository.findByUserIdAndPlatformAccountId(userId, accountId) - .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_NOT_ACCOUNT_OWNER)); - - // 영향받는 Project ID 수집 (AdCampaign 삭제 전에 미리 확보 — 삭제 후엔 못 찾음) - List affectedProjectIds = adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); + // 권한 검증 & 영향받는 projectId 수집 (짧은 read-only 트랜잭션) + List projectIds = platformDataCleanupExecutor.verifyAndCollectProjectIds(userId, orgId, accountId); int chunkDeleted; // 하나의 청크 당 삭제 갯수 long totalClickLogDeleted = 0L; // ClickLog 전체 삭제 갯수 @@ -196,24 +171,12 @@ public void disconnectPlatform(Long userId, Long orgId, Long accountId) { } while (chunkDeleted > 0); log.info("MetricFact 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalMetricFactDeleted); - // AdCampaign 삭제 (Cascade ALL → AdGroup → AdContent 자동 정리) - List campaigns = adCampaignRepository.findByPlatformAccount(platformAccount); - if (!campaigns.isEmpty()) { - adCampaignRepository.deleteAll(campaigns); - adCampaignRepository.flush(); - } - - // PlatformConnection 정리 - platformConnectionRepository.deleteByPlatformAccount_Id(accountId); - - // PlatformAccount 정리 - platformAccountRepository.delete(platformAccount); + // AdCampaign + PlatformConnection + PlatformAccount 삭제 진행 + platformDataCleanupExecutor.deleteAccountAndRelations(accountId); - // AdCampaign 이 0이 된 Project 자동 정리 - for (Long projectId : affectedProjectIds) { - if (adCampaignRepository.countByProject_Id(projectId) == 0) { - projectRepository.deleteById(projectId); - } + // 비어있는 Project 엔티티 삭제 + for (Long projectId : projectIds) { + platformDataCleanupExecutor.deleteEmptyProject(projectId); } } From 19f3009128a2132b4b98afd95043a39688c06aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Mon, 1 Jun 2026 23:11:31 +0900 Subject: [PATCH 08/13] =?UTF-8?q?:art:=20style:=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/domain/service/PlatformDataCleanupExecutor.java | 1 + .../domains/platform/domain/service/PlatformServiceImpl.java | 2 ++ 2 files changed, 3 insertions(+) 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 index 26368f8b..70c4ddc0 100644 --- 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 @@ -98,6 +98,7 @@ public void deleteAccountAndRelations(Long accountId) { if (!campaigns.isEmpty()) { adCampaignRepository.deleteAll(campaigns); adCampaignRepository.flush(); + //AdCampaign 삭제 시 CascadeType.ALL 로 인해 연관된 AdGroup, AdContent 도 함꼐 제거됨 } List connections = platformConnectionRepository.findAllByPlatformAccount_Id(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 6fac790b..6c4bd003 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 @@ -147,6 +147,7 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o } // 광고 플랫폼 연동 해제 + // 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리 @Override @Transactional(propagation = Propagation.NOT_SUPPORTED) public void disconnectPlatform(Long userId, Long orgId, Long accountId) { @@ -175,6 +176,7 @@ public void disconnectPlatform(Long userId, Long orgId, Long accountId) { platformDataCleanupExecutor.deleteAccountAndRelations(accountId); // 비어있는 Project 엔티티 삭제 + // -> AdCampaign, AdGroup, AdContent 삭제로 인해 연관된 광고 객체가 없는 Project 엔티티 삭제 for (Long projectId : projectIds) { platformDataCleanupExecutor.deleteEmptyProject(projectId); } From ac597b01645490c5646ca8da042ec59f2dd0d7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Wed, 3 Jun 2026 21:42:09 +0900 Subject: [PATCH 09/13] =?UTF-8?q?:sparkles:=20feat:=20=EB=B9=88=20Project?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C?= =?UTF-8?q?=20=EC=97=B0=EA=B4=80=EB=90=9C=20MetricFact=20=EA=B0=80=200?= =?UTF-8?q?=EC=9D=B8=EC=A7=80=20=EA=B2=80=EC=A6=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/repository/MetricFactRepository.java | 2 ++ .../platform/domain/service/PlatformDataCleanupExecutor.java | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) 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 d72e6af9..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 @@ -239,4 +239,6 @@ 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/platform/domain/service/PlatformDataCleanupExecutor.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java index 70c4ddc0..828ef2db 100644 --- 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 @@ -114,7 +114,8 @@ public void deleteAccountAndRelations(Long accountId) { //빈 Project 1개 삭제 @Transactional public void deleteEmptyProject(Long projectId) { - if (adCampaignRepository.countByProject_Id(projectId) == 0) { + // 연관된 AdCampaign, MetricFact 가 없을 경우에만 Project 삭제 진행 + if (adCampaignRepository.countByProject_Id(projectId) == 0 && metricFactRepository.countByProject_Id(projectId) == 0) { projectRepository.deleteById(projectId); } } From 2a2ca5f10cbfdeda3a0bbfb6e98ca07d6d0c648d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 5 Jun 2026 15:00:04 +0900 Subject: [PATCH 10/13] =?UTF-8?q?:sparkles:=20feat:=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EB=82=B4=EB=B6=80=20=ED=98=B8=EC=B6=9C=EC=9A=A9=20?= =?UTF-8?q?=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EA=B3=84=EC=A0=95=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20(=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=ED=99=9C=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PlatformDataCleanupExecutor.java | 6 ++++++ .../platform/domain/service/PlatformService.java | 3 +++ .../domain/service/PlatformServiceImpl.java | 15 +++++++++++++++ .../repository/PlatformConnectionRepository.java | 4 ++++ 4 files changed, 28 insertions(+) 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 index 828ef2db..7169dd5f 100644 --- 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 @@ -67,6 +67,12 @@ public List verifyAndCollectProjectIds(Long userId, Long orgId, Long accou 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) { 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 c009007b..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 @@ -11,4 +11,7 @@ public interface PlatformService { PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long orgId, PlatformRequest.PlatformAccount request); 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 6c4bd003..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 @@ -153,7 +153,22 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o public void disconnectPlatform(Long userId, Long orgId, Long accountId) { // 권한 검증 & 영향받는 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 전체 삭제 갯수 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 392f9baf..12c8627a 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 @@ -46,4 +46,8 @@ public interface PlatformConnectionRepository extends JpaRepository 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); } From 6bb9a495150fb7f0d61d43cf5568358b6d3ed839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 5 Jun 2026 15:00:54 +0900 Subject: [PATCH 11/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=20=EC=A0=84=EC=9A=A9=20Organization=20Sof?= =?UTF-8?q?t=20Delete=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/OrgService.java | 3 +++ .../domain/service/OrgServiceImpl.java | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) 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 From f20f1cd5cb83ac72efb86c8df132fd48502b06c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 5 Jun 2026 15:01:40 +0900 Subject: [PATCH 12/13] =?UTF-8?q?:sparkles:=20feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20=ED=94=8C=EB=9E=AB=ED=8F=BC?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EC=97=AC=EB=B6=80=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20Hard=20Delete=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/domain/service/UserService.java | 18 +++--------------- .../service/scheduler/UserDeleteScheduler.java | 12 ++++++++++++ .../user/exception/code/UserErrorCode.java | 1 - 3 files changed, 15 insertions(+), 16 deletions(-) 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", "이메일 인증이 진행되지 않았습니다."), From a8ce99a6a9796516f45094ccc2d16811a78cdde0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 5 Jun 2026 15:02:26 +0900 Subject: [PATCH 13/13] =?UTF-8?q?:recycle:=20refactor:=20PlatformConnectio?= =?UTF-8?q?nRepository=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/repository/PlatformConnectionRepository.java | 3 --- 1 file changed, 3 deletions(-) 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 12c8627a..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);