Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
23f19a7
:sparkles: feat: 플랫폼 연동 해제를 위한 PlatformErrorCode 오류 코드 추가
ojy0903 May 22, 2026
597823b
:sparkles: feat: 플랫폼 연동 해제를 위한 Repository 메서드 추가
ojy0903 May 22, 2026
973f2b3
:sparkles: feat: 플랫폼 연동 해제를 별도 Executor 클래스 추가
ojy0903 May 22, 2026
db323f2
:sparkles: feat: 플랫폼 연동 해제를 위한 Service 메서드 추가
ojy0903 May 22, 2026
b25cdda
:sparkles: feat: 플랫폼 연동 해제 API 추가
ojy0903 May 22, 2026
8e640ae
:sparkles: feat: PlatformConnection 삭제 시 FK 위반 보완
ojy0903 May 23, 2026
f5201e7
:sparkles: feat: PlatformConnection 삭제 시 FK 위반 보완 & DB 풀 고갈 방지를 위한 로직 분리
ojy0903 May 23, 2026
e4a3abf
Merge remote-tracking branch 'origin/develop' into feat/#139
ojy0903 May 26, 2026
19f3009
:art: style: 소스코드 주석 추가
ojy0903 Jun 1, 2026
17edad5
Merge remote-tracking branch 'origin/develop' into feat/#139
ojy0903 Jun 3, 2026
ac597b0
:sparkles: feat: 빈 Project 엔티티 삭제 시 연관된 MetricFact 가 0인지 검증하는 로직 추가
ojy0903 Jun 3, 2026
2a2ca5f
:sparkles: feat: 시스템 내부 호출용 플랫폼 계정 단위 연동 해제 로직 추가 (회원탈퇴 시 활용)
ojy0903 Jun 5, 2026
6bb9a49
:sparkles: feat: 회원 탈퇴 전용 Organization Soft Delete 메서드 추가
ojy0903 Jun 5, 2026
f20f1cd
:sparkles: feat: 회원 탈퇴 시 플랫폼 연동 여부 검증 제거 및 Hard Delete 시 자동 연동 해제 로직 추가
ojy0903 Jun 5, 2026
a8ce99a
:recycle: refactor: PlatformConnectionRepository 미사용 메서드 제거
ojy0903 Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,15 @@ Long sumBudgetsByUserIdAndOrgIdAndProvider(@Param("userId") Long userId, @Param(
Optional<AdCampaign> findByExternalCampaignIdAndPlatformAccount(String externalCampaignId, PlatformAccount platformAccount);

Optional<AdCampaign> findByPlatformAccountAndExternalCampaignId(PlatformAccount platformAccount, String externalCampaignId);

// PlatformAccount 연동 해제 시 사용
List<AdCampaign> 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<Long> findDistinctProjectIdsByPlatformAccountId(@Param("platformAccountId") Long platformAccountId);

// 특정 Project 에 남아있는 AdCampaign 수 (빈 Project 판단용)
long countByProject_Id(Long projectId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -227,4 +228,17 @@ Optional<MetricFact> findByAdContentAndTimeBucketAndGrain(
);

Optional<MetricFact> findByPlatformAccount_IdAndAdContent_IdAndTimeBucket(Long platformAccountId, Long adContentId, LocalDateTime timeBucket);

// PlatformAccount 연동 해제 시 청크 단위 정리용
@Modifying
@Query(value = "DELETE FROM metric_fact " +
"WHERE platform_account_id = :platformAccountId " +
"LIMIT :batchSize",
nativeQuery = true)
int deleteByPlatformAccountIdInBatch(
@Param("platformAccountId") Long platformAccountId,
@Param("batchSize") int batchSize
);

long countByProject_Id(Long projectId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClickLog, Long> {
}

// 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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrgMember> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.whereyouad.WhereYouAd.domains.platform.domain.service;

import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign;
import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository;
import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository;
import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickLogRepository;
import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgRole;
import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.OrgMember;
import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository;
import com.whereyouad.WhereYouAd.domains.platform.exception.PlatformHandler;
import com.whereyouad.WhereYouAd.domains.platform.exception.code.PlatformErrorCode;
import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformAccount;
import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection;
import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformAccountRepository;
import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformConnectionRepository;
import com.whereyouad.WhereYouAd.domains.project.persistence.repository.ProjectRepository;
import com.whereyouad.WhereYouAd.domains.user.exception.code.UserErrorCode;
import com.whereyouad.WhereYouAd.domains.user.exception.handler.UserHandler;
import com.whereyouad.WhereYouAd.domains.user.persistence.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class PlatformDataCleanupExecutor {

private static final int BATCH_SIZE = 1000;

private final UserRepository userRepository;
private final OrgMemberRepository orgMemberRepository;
private final PlatformAccountRepository platformAccountRepository;
private final PlatformConnectionRepository platformConnectionRepository;
private final AdCampaignRepository adCampaignRepository;
private final ProjectRepository projectRepository;
private final ClickLogRepository clickLogRepository;
private final MetricFactRepository metricFactRepository;

// 권한 & 소유자 검증 + 삭제에 영향받는 projectId 반환 (read-only & 짧은 트랜잭션)
@Transactional(readOnly = true)
public List<Long> verifyAndCollectProjectIds(Long userId, Long orgId, Long accountId) {
userRepository.findById(userId)
.orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND));

OrgMember orgMember = orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
.orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ORG_MEMBER_NOT_FOUND));

if (orgMember.getRole() != OrgRole.ADMIN) {
throw new PlatformHandler(PlatformErrorCode.PLATFORM_FORBIDDEN);
}

PlatformAccount platformAccount = platformAccountRepository.findById(accountId)
.orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND));

if (!platformAccount.getOrganization().getId().equals(orgId)) {
throw new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_BELONG_TO_ORG);
}

platformConnectionRepository.findByUserIdAndPlatformAccountId(userId, accountId)
.orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_NOT_ACCOUNT_OWNER));

return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId);
}

// 요청자 권한 검증 없이 삭제에 영향받는 projectId 만 수집 (회원 탈퇴 스케줄러 등 시스템 내부 호출용)
@Transactional(readOnly = true)
public List<Long> collectProjectIds(Long accountId) {
return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId);
}

// 청크 단위로 ClickLog 삭제 — 메인 트랜잭션과 분리
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int deleteClickLogChunk(Long platformAccountId) {
int deleted = clickLogRepository.deleteByPlatformAccountIdInBatch(platformAccountId, BATCH_SIZE);
if (deleted > 0) {
log.debug("ClickLog 청크 삭제 - platformAccountId={}, deleted={}", platformAccountId, deleted);
}
return deleted;
}

// 청크 단위로 MetricFact 삭제
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int deleteMetricFactChunk(Long platformAccountId) {
int deleted = metricFactRepository.deleteByPlatformAccountIdInBatch(platformAccountId, BATCH_SIZE);
if (deleted > 0) {
log.debug("MetricFact 청크 삭제 - platformAccountId={}, deleted={}", platformAccountId, deleted);
}
return deleted;
}

// AdCampaign + PlatformConnection + PlatformAccount 원자적 삭제
@Transactional
public void deleteAccountAndRelations(Long accountId) {
PlatformAccount platformAccount = platformAccountRepository.findById(accountId)
.orElseThrow(() -> new PlatformHandler(PlatformErrorCode.PLATFORM_ACCOUNT_NOT_FOUND));

List<AdCampaign> campaigns = adCampaignRepository.findByPlatformAccount(platformAccount);

if (!campaigns.isEmpty()) {
adCampaignRepository.deleteAll(campaigns);
adCampaignRepository.flush();
//AdCampaign 삭제 시 CascadeType.ALL 로 인해 연관된 AdGroup, AdContent 도 함꼐 제거됨
}

List<PlatformConnection> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ public interface PlatformService {

void disconnectPlatform(Long userId, Long orgId, Long accountId);

// 회원 탈퇴 스케줄러 등 시스템 내부 호출용 - 요청자 권한 검증 없이 계정 단위 연동 해제
void disconnectAccountBySystem(Long accountId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
Expand All @@ -42,6 +43,7 @@ public class PlatformServiceImpl implements PlatformService {
private final OrgMemberRepository orgMemberRepository;
private final PlatformAccountRepository platformAccountRepository;
private final PlatformConnectionRepository platformConnectionRepository;
private final PlatformDataCleanupExecutor platformDataCleanupExecutor;
private final NaverClient naverClient;
private final NaverAdAuthStrategy naverAdAuthStrategy;

Expand Down Expand Up @@ -144,10 +146,55 @@ public PlatformResponse.PlatformAccount updateNaverAdAccount(Long userId, Long o
return PlatformConverter.toPlatformAccountResponse(platformAccount);
}

// 광고 플랫폼 연동 해제
// 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void disconnectPlatform(Long userId, Long orgId, Long accountId) {
// TODO: 관련된 PlatformConnection, PlatformAccount, 광고 도메인 엔티티 제거 로직 개발
return;
// 권한 검증 & 영향받는 projectId 수집 (짧은 read-only 트랜잭션)
List<Long> projectIds = platformDataCleanupExecutor.verifyAndCollectProjectIds(userId, orgId, accountId);
cleanupAccount(accountId, projectIds);
}

// 회원 탈퇴 스케줄러 등 시스템 내부 호출용 플랫폼 연동 해제
// 권한 검증 없이 계정 단위 정리
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void disconnectAccountBySystem(Long accountId) {
List<Long> projectIds = platformDataCleanupExecutor.collectProjectIds(accountId);
cleanupAccount(accountId, projectIds);
}

// 계정 단위 데이터 정리 메서드화
// ClickLog / MetricFact 청크 삭제 → AdCampaign + PlatformConnection + PlatformAccount 삭제 → 빈 Project 삭제
// 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리
private void cleanupAccount(Long accountId, List<Long> projectIds) {
int chunkDeleted; // 하나의 청크 당 삭제 갯수
long totalClickLogDeleted = 0L; // ClickLog 전체 삭제 갯수
long totalMetricFactDeleted = 0L; // MetricFact 전체 삭제 갯수

// ClickLog 청크 정리 (REQUIRES_NEW)
do {
chunkDeleted = platformDataCleanupExecutor.deleteClickLogChunk(accountId);
totalClickLogDeleted += chunkDeleted;
} while (chunkDeleted > 0);
log.info("ClickLog 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalClickLogDeleted);

// MetricFact 청크 정리 (REQUIRES_NEW) — Project 삭제 단계에서 FK 위반 방지
do {
chunkDeleted = platformDataCleanupExecutor.deleteMetricFactChunk(accountId);
totalMetricFactDeleted += chunkDeleted;
} while (chunkDeleted > 0);
log.info("MetricFact 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalMetricFactDeleted);

// AdCampaign + PlatformConnection + PlatformAccount 삭제 진행
platformDataCleanupExecutor.deleteAccountAndRelations(accountId);

// 비어있는 Project 엔티티 삭제
// -> AdCampaign, AdGroup, AdContent 삭제로 인해 연관된 광고 객체가 없는 Project 엔티티 삭제
for (Long projectId : projectIds) {
platformDataCleanupExecutor.deleteEmptyProject(projectId);
}
}

private void validateNaverCredentials(String customerId, String encryptedApiKey, String encryptedSecretKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "조직과 연결된 인증 정보를 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ public interface PlatformConnectionRepository extends JpaRepository<PlatformConn
// 조직 ID와 플랫폼으로 등록된 연동 정보(Account/Connection) 목록 조회
List<PlatformConnection> findByPlatformAccount_Organization_IdAndPlatformAccount_Provider(Long orgId, Provider provider);

// 사용자 ID로 등록된 연동 정보 목록 조회 (회원 탈퇴 시 정리용)
List<PlatformConnection> findByUser_Id(Long userId);

// 사용자 ID와 플랫폼으로 등록된 연동 정보 목록 조회
List<PlatformConnection> findByUser_IdAndPlatformAccount_Provider(Long userId, Provider provider);

Expand All @@ -43,4 +40,11 @@ public interface PlatformConnectionRepository extends JpaRepository<PlatformConn
"JOIN FETCH c.platformAccount pa " +
"WHERE c.user.id = :userId AND pa.organization.id = :orgId")
List<PlatformConnection> findByUserIdAndOrgId(@Param("userId") Long userId, @Param("orgId") Long orgId);

// PlatformAccount 연동 해제 시 해당 계정에 연결된 모든 connection 일괄 삭제를 위한 List 조회
List<PlatformConnection> findAllByPlatformAccount_Id(Long platformAccountId);

// 회원 Hard Delete 시 해당 유저가 연동한 PlatformAccount id 목록 조회 (시스템 자동 연동 해제용)
@Query("SELECT DISTINCT c.platformAccount.id FROM PlatformConnection c WHERE c.user.id = :userId")
List<Long> findDistinctAccountIdsByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.whereyouad.WhereYouAd.domains.platform.domain.service.PlatformService;
import com.whereyouad.WhereYouAd.domains.platform.presentation.docs.PlatformControllerDocs;
import com.whereyouad.WhereYouAd.global.response.DataResponse;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -61,8 +60,6 @@ public ResponseEntity<DataResponse<PlatformResponse.PlatformAccount>> updateNave
);
}


@Hidden
@DeleteMapping("/{orgId}/accounts/{accountId}")
public ResponseEntity<DataResponse<String>> disconnectPlatform(
@AuthenticationPrincipal(expression = "userId") Long userId,
Expand Down
Loading
Loading