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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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/25] =?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); From 840fe5db182a506b34e1ca3ec63dc2f6fefbb117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Thu, 11 Jun 2026 17:31:15 +0900 Subject: [PATCH 14/25] =?UTF-8?q?:recycle:=20refactor:=20=ED=94=8C?= =?UTF-8?q?=EB=9E=AB=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4=EA=B0=95=20(=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PlatformDataCleanupExecutor.java | 5 +++++ .../service/scheduler/UserDeleteScheduler.java | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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 7169dd5f..1379c520 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 @@ -99,6 +99,11 @@ public void deleteAccountAndRelations(Long accountId) { PlatformAccount platformAccount = platformAccountRepository.findById(accountId) .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND)); + if (platformAccount == null) { + log.info("이미 삭제된 PlatformAccount - accountId={}, 정리 skip", accountId); + return; + } + List campaigns = adCampaignRepository.findByPlatformAccount(platformAccount); if (!campaigns.isEmpty()) { 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 58692279..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 @@ -40,8 +40,22 @@ public void hardDeleteUsers() { // 광고 플랫폼 연동 자동 해제 (계정 + 연관 광고 엔티티/ClickLog/MetricFact/비어있는 Project 정리) // PlatformAccount → Organization FK 위반 방지를 위해 조직 Hard Delete 전에 수행 List accountIds = platformConnectionRepository.findDistinctAccountIdsByUserId(userId); + + boolean isAllCleaned = true; for (Long accountId : accountIds) { - platformService.disconnectAccountBySystem(accountId); + 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 From 8f2a6fbd5109e8be98dac4ed432e5f90cdb87c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:18:52 +0900 Subject: [PATCH 15/25] =?UTF-8?q?:sparkles:=20feat:=20PlatformAccount=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=8B=88=EC=8A=A4=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/platform/persistence/entity/PlatformAccount.java | 5 +++++ 1 file changed, 5 insertions(+) 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; + } + } From c4a0de5ba1c679744ee3437e0e3fae01a006bf00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:19:13 +0900 Subject: [PATCH 16/25] =?UTF-8?q?:sparkles:=20feat:=20PlatformAccount=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20Repository=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=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 --- .../repository/PlatformAccountRepository.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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); // 계정이 존재하는지 확인 From 061daef522b44f37351d9b02c9a53b0ffdb6689c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:23:18 +0900 Subject: [PATCH 17/25] =?UTF-8?q?:sparkles:=20feat:=20PlatformServiceImpl?= =?UTF-8?q?=20=EB=82=B4=EB=B6=80=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20Soft=20De?= =?UTF-8?q?lete=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20&=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=97=B0=EB=8F=99=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20Soft=20Delete=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A7=8C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/PlatformServiceImpl.java | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) 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 c5260d28..331f1482 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,7 @@ 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.exception.PlatformHandler; import com.whereyouad.WhereYouAd.domains.platform.exception.code.PlatformErrorCode; import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformAccount; @@ -104,8 +105,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); @@ -146,18 +149,43 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o return PlatformConverter.toPlatformAccountResponse(platformAccount); } - // 광고 플랫폼 연동 해제 - // 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리 + // 광고 플랫폼 연동 해제 (수동 요청) + // 권한/소유자 검증 후 상태만 DISCONNECTED 로 변경하고 즉시 반환, 실제 데이터 삭제는 PlatformAccountCleanupScheduler 에서 비동기 진행 @Override - @Transactional(propagation = Propagation.NOT_SUPPORTED) public void disconnectPlatform(Long userId, Long orgId, Long accountId) { - // 권한 검증 & 영향받는 projectId 수집 (짧은 read-only 트랜잭션) - List projectIds = platformDataCleanupExecutor.verifyAndCollectProjectIds(userId, orgId, accountId); - cleanupAccount(accountId, projectIds); + // 검증 로직 + 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) { From ead7796eee9bab9510266fbbcd338c9513893ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:23:41 +0900 Subject: [PATCH 18/25] =?UTF-8?q?:sparkles:=20feat:=20PlatformDataCleanupE?= =?UTF-8?q?xecutor=20=EB=82=B4=EB=B6=80=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PlatformDataCleanupExecutor.java | 36 +------------------ 1 file changed, 1 insertion(+), 35 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 1379c520..69a792ed 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 @@ -4,9 +4,6 @@ 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; @@ -14,9 +11,6 @@ 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; @@ -32,8 +26,6 @@ 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; @@ -41,33 +33,7 @@ public class PlatformDataCleanupExecutor { private final ClickLogRepository clickLogRepository; private final MetricFactRepository metricFactRepository; - // 권한 & 소유자 검증 + 삭제에 영향받는 projectId 반환 (read-only & 짧은 트랜잭션) - @Transactional(readOnly = true) - public List verifyAndCollectProjectIds(Long userId, Long orgId, Long accountId) { - userRepository.findById(userId) - .orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND)); - - OrgMember orgMember = orgMemberRepository.findByUserIdAndOrgId(userId, orgId) - .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ORG_MEMBER_NOT_FOUND)); - - if (orgMember.getRole() != OrgRole.ADMIN) { - throw new PlatformHandler(PlatformErrorCode.PLATFORM_FORBIDDEN); - } - - PlatformAccount platformAccount = platformAccountRepository.findById(accountId) - .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND)); - - if (!platformAccount.getOrganization().getId().equals(orgId)) { - throw new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_BELONG_TO_ORG); - } - - platformConnectionRepository.findByUserIdAndPlatformAccountId(userId, accountId) - .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_NOT_ACCOUNT_OWNER)); - - return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); - } - - // 요청자 권한 검증 없이 삭제에 영향받는 projectId 만 수집 (회원 탈퇴 스케줄러 등 시스템 내부 호출용) + // 삭제에 영향받는 projectId 수집 (수동 연동 해제 정리 / 회원 탈퇴 스케줄러 등 시스템 내부 호출용) @Transactional(readOnly = true) public List collectProjectIds(Long accountId) { return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); From 32bb10252dfd53790800a2c94fcd5ab883cc3d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:25:38 +0900 Subject: [PATCH 19/25] =?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=EC=88=98=EB=8F=99=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20Hard=20Delete=20=EC=B2=98=EB=A6=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformAccountCleanupScheduler.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformAccountCleanupScheduler.java 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..404b1b7f --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformAccountCleanupScheduler.java @@ -0,0 +1,44 @@ +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는 맨 마지막에 삭제) + @Scheduled(cron = "0 0 * * * *", 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 From 3a509eadbeef5edf8a4d5f869dd421fc27a49a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:26:09 +0900 Subject: [PATCH 20/25] =?UTF-8?q?:recycle:=20refactor:=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=8F=20=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/platform/domain/service/PlatformServiceImpl.java | 1 + .../service/{ => scheduler}/PlatformDataCleanupExecutor.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/{ => scheduler}/PlatformDataCleanupExecutor.java (99%) 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 331f1482..5292586d 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 @@ -9,6 +9,7 @@ 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; 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/scheduler/PlatformDataCleanupExecutor.java similarity index 99% rename from src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformDataCleanupExecutor.java rename to src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java index 69a792ed..9ed1d1da 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/scheduler/PlatformDataCleanupExecutor.java @@ -1,4 +1,4 @@ -package com.whereyouad.WhereYouAd.domains.platform.domain.service; +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; From cd18ddcd640233505e48df34e2a3cbd8814f3c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:26:33 +0900 Subject: [PATCH 21/25] =?UTF-8?q?:sparkles:=20feat:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=88=98=EB=8F=99=20=EC=97=B0=EB=8F=99=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/platform/presentation/PlatformController.java | 2 +- .../platform/presentation/docs/PlatformControllerDocs.java | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 da1073f7..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 @@ -69,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 3d512248..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 @@ -69,12 +69,15 @@ ResponseEntity> updateNaverAdAcco @Operation( summary = "광고 플랫폼 계정 연동 해제 API", - description = "본인이 등록한 광고 플랫폼 계정의 연동을 해제하고, 해당 계정에 종속된 모든 광고 데이터를 정리합니다. \n\n" + + description = "본인이 등록한 광고 플랫폼 계정의 연동을 해제합니다. \n\n" + + "요청 시점에는 계정 상태만 '삭제 대기(DISCONNECTED)'로 변경하고 즉시 응답하며, " + + "해당 계정에 종속된 모든 광고 데이터(ClickLog/MetricFact/AdCampaign/Connection/빈 Project)는 " + + "스케줄러가 비동기로 정리합니다.\n\n" + "**주의** : 삭제한 플랫폼 계정에 관련된 모든 광고 데이터가 삭제되며, 복구 불가합니다. 사용자에게 안내 필요\n\n" + "ADMIN 권한을 가진 조직 멤버 중 본인이 직접 등록한(광고 플랫폼 연동을 진행한 회원 본인만) 계정만 해제할 수 있습니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "연동 해제 및 관련 광고 데이터 정리 성공"), + @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 - 본인이 등록한 광고 계정이 아님"), From 6b4a0476b67a53ec1a435264d1b95c215ca509e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:34:24 +0900 Subject: [PATCH 22/25] =?UTF-8?q?:recycle:=20refactor:=20=ED=94=8C?= =?UTF-8?q?=EB=9E=AB=ED=8F=BC=20=EC=88=98=EB=8F=99=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/scheduler/PlatformAccountCleanupScheduler.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 404b1b7f..b92300f1 100644 --- 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 @@ -19,8 +19,9 @@ public class PlatformAccountCleanupScheduler { private final PlatformService platformService; // 수동 연동 해제(DISCONNECTED 마킹)된 PlatformAccount 의 실제 데이터 정리 - // (cleanupAccount 는 ClickLog/MetricFact/AdCampaign/Connection 먼저 삭제, PlatformAccount는 맨 마지막에 삭제) - @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + // 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()) { From 64e91c0a448e300983fe0957507ee7908e5039a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 16:40:00 +0900 Subject: [PATCH 23/25] =?UTF-8?q?:art:=20style:=20=ED=94=8C=EB=9E=AB?= =?UTF-8?q?=ED=8F=BC=20=EC=88=98=EB=8F=99=20=EC=97=B0=EB=8F=99=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/scheduler/PlatformAccountCleanupScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b92300f1..8dd365c4 100644 --- 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 @@ -40,6 +40,6 @@ public void cleanupDeletedAccounts() { } } - log.info("연동 해제(DISCONNECTED) 계정 정리 완료 - 대상: {}, 성공: {}", accountIds.size(), successCount); + log.info("연동 해제(DISCONNECTED) 계정 정리 완료 - 대상 갯수: {}, 성공 갯수: {}", accountIds.size(), successCount); } } \ No newline at end of file From 62ab0cfb5fbd663dd7ed81732ed4a2b363bf0c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Fri, 12 Jun 2026 21:09:50 +0900 Subject: [PATCH 24/25] =?UTF-8?q?:recycle:=20refactor:=20=EB=B9=88=20Proje?= =?UTF-8?q?ct=20=EC=82=AD=EC=A0=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=ED=95=98=EC=97=AC=20=EC=9B=90=EC=9E=90?= =?UTF-8?q?=EC=84=B1=20=ED=99=95=EB=B3=B4=20(=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/PlatformServiceImpl.java | 11 ++----- .../PlatformDataCleanupExecutor.java | 33 +++++++++---------- 2 files changed, 19 insertions(+), 25 deletions(-) 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 5292586d..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 @@ -216,14 +216,9 @@ private void cleanupAccount(Long accountId, List projectIds) { } while (chunkDeleted > 0); log.info("MetricFact 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalMetricFactDeleted); - // AdCampaign + PlatformConnection + PlatformAccount 삭제 진행 - platformDataCleanupExecutor.deleteAccountAndRelations(accountId); - - // 비어있는 Project 엔티티 삭제 - // -> AdCampaign, AdGroup, AdContent 삭제로 인해 연관된 광고 객체가 없는 Project 엔티티 삭제 - for (Long projectId : projectIds) { - platformDataCleanupExecutor.deleteEmptyProject(projectId); - } + // 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/PlatformDataCleanupExecutor.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java index 9ed1d1da..4bd4931d 100644 --- 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 @@ -4,8 +4,6 @@ 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.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; @@ -59,41 +57,42 @@ public int deleteMetricFactChunk(Long platformAccountId) { return deleted; } - // AdCampaign + PlatformConnection + PlatformAccount 원자적 삭제 + // AdCampaign + PlatformConnection + PlatformAccount + 비어있는 Project 원자적 삭제 @Transactional - public void deleteAccountAndRelations(Long accountId) { + public void deleteAccountAndRelations(Long accountId, List projectIds) { PlatformAccount platformAccount = platformAccountRepository.findById(accountId) - .orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND)); + .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 도 함꼐 제거됨 + // AdCampaign 삭제 시 CascadeType.ALL 로 인해 연관된 AdGroup, AdContent 도 함께 제거됨 } + // PlatformConnection 제거 List connections = platformConnectionRepository.findAllByPlatformAccount_Id(accountId); - if (!connections.isEmpty()) { platformConnectionRepository.deleteAll(connections); platformConnectionRepository.flush(); } - platformAccountRepository.delete(platformAccount); - } - - //빈 Project 1개 삭제 - @Transactional - public void deleteEmptyProject(Long projectId) { - // 연관된 AdCampaign, MetricFact 가 없을 경우에만 Project 삭제 진행 - if (adCampaignRepository.countByProject_Id(projectId) == 0 && metricFactRepository.countByProject_Id(projectId) == 0) { - projectRepository.deleteById(projectId); + // 빈 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 From 112a93dbf383760ff39c2a7427ebab4cd10f60fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Tue, 16 Jun 2026 14:43:06 +0900 Subject: [PATCH 25/25] =?UTF-8?q?:art:=20style:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API=20Swagger=20Docs=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/user/presentation/docs/UserControllerDocs.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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