fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분. - 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화 - 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할 - 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일 - 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope) - 비밀번호 정책 문구를 도메인 중립 라벨로 정정 - 정적 검사 2종·검증 스킬 설계 정합성 축 신설
This commit is contained in:
1 parent
49c155509e
commit
4e1ec6e001
136 files changed
+7016
-504
No files matched your search
+255
-52
@@ -10,6 +10,7 @@ use App\Extension\HookManager;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Helpers\TimezoneHelper;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserService
|
||||
@@ -170,6 +172,23 @@ class UserService
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자가 상태만 '탈퇴'로 바꾸는 경로도 정식 탈퇴 로직으로 통일한다.
|
||||
//
|
||||
// 여기서 status/withdrawn_at 을 먼저 저장하면 withdraw() 의 멱등 가드가
|
||||
// 이미 탈퇴한 계정으로 판정해 익명화와 훅이 조용히 생략된다.
|
||||
// 따라서 상태 필드는 제거하고, 다른 필드 갱신을 마친 뒤 withdrawUser() 에 위임한다.
|
||||
$withdrawViaStatus = isset($data['status'])
|
||||
&& $data['status'] === UserStatus::Withdrawn->value
|
||||
&& ! $user->isWithdrawn();
|
||||
|
||||
if ($withdrawViaStatus) {
|
||||
unset($data['status']);
|
||||
|
||||
// 차단 대상(관리자/수퍼관리자)이면 다른 필드도 저장되지 않아야 하므로
|
||||
// 갱신 전에 가드를 평가한다.
|
||||
$this->assertWithdrawable($user);
|
||||
}
|
||||
|
||||
// 상태 변경 감지 및 타임스탬프 자동 설정
|
||||
$oldStatus = $user->status;
|
||||
$newStatus = $data['status'] ?? null;
|
||||
@@ -178,7 +197,8 @@ class UserService
|
||||
$newStatusEnum = UserStatus::from($newStatus);
|
||||
$data = match ($newStatusEnum) {
|
||||
UserStatus::Blocked => array_merge($data, ['blocked_at' => now()]),
|
||||
UserStatus::Withdrawn => array_merge($data, ['withdrawn_at' => now()]),
|
||||
// 탈퇴는 위 위임 경로(withdrawUser)가 전담하므로 여기로는 도달하지 않는다.
|
||||
UserStatus::Withdrawn => $data,
|
||||
UserStatus::Active => array_merge($data, ['blocked_at' => null, 'withdrawn_at' => null]),
|
||||
UserStatus::Inactive => $data,
|
||||
};
|
||||
@@ -187,22 +207,45 @@ class UserService
|
||||
// 스냅샷 캡처 (ChangeDetector용)
|
||||
$snapshot = $user->toArray();
|
||||
|
||||
$this->userRepository->update($user, $data);
|
||||
// 탈퇴 위임 시 아바타는 커밋 후에 지운다 (트랜잭션 안에서 지우면 롤백돼도 파일은 안 돌아온다).
|
||||
$avatarAttachment = $withdrawViaStatus ? $user->avatarAttachment : null;
|
||||
|
||||
// 상태가 Active 외로 변경되었으면 토큰 삭제 (즉시 로그아웃)
|
||||
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
// 프로필 갱신·토큰 정리·역할 동기화가 서로 어긋나지 않도록 하나로 묶는다.
|
||||
//
|
||||
// 상태 변경 경유 탈퇴는 **탈퇴까지 같은 트랜잭션**에 넣는다 — 프로필 갱신을
|
||||
// 먼저 커밋하고 탈퇴를 따로 커밋하면, 탈퇴가 실패했을 때 이름·이메일 변경만
|
||||
// 남아 "전부 성공하거나 전부 취소" 가 그 경로에서만 깨진다.
|
||||
DB::transaction(function () use ($user, $data, $newStatus, $oldStatus, $roleIds, $withdrawViaStatus) {
|
||||
$this->userRepository->update($user, $data);
|
||||
|
||||
// 역할 동기화
|
||||
// 상태가 Active 외로 변경되었으면 토큰 삭제 (즉시 로그아웃)
|
||||
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
// 역할 동기화
|
||||
if ($roleIds !== null) {
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
|
||||
// 익명화 + before_withdraw 훅 + 연계 데이터 정리 (본인 탈퇴와 동일 코드)
|
||||
if ($withdrawViaStatus) {
|
||||
$this->performWithdrawWrites($user);
|
||||
}
|
||||
});
|
||||
|
||||
// 커밋 후 부수효과 (캐시는 롤백 대상이 아니다)
|
||||
if ($roleIds !== null) {
|
||||
$user->roles()->sync($roleIds);
|
||||
$user->flushPermissionCaches();
|
||||
}
|
||||
|
||||
// After 훅: 사용자 객체와 원본 데이터, 스냅샷 전달
|
||||
HookManager::doAction('core.user.after_update', $user, $originalData, $snapshot);
|
||||
|
||||
if ($withdrawViaStatus) {
|
||||
$this->runWithdrawAfterEffects($user, $avatarAttachment);
|
||||
}
|
||||
|
||||
// 비밀번호 변경 시 알림 훅 발화 — 발송은 NotificationHookListener가 처리
|
||||
if ($passwordChanged) {
|
||||
HookManager::doAction('core.auth.after_password_changed', $user);
|
||||
@@ -214,12 +257,93 @@ class UserService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($e instanceof CannotDeleteSuperAdminException) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.update_failed', ['error' => $e->getMessage()])],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 가능한 계정인지 검사합니다.
|
||||
*
|
||||
* 탈퇴 경로(본인 탈퇴 / 관리자 상태 변경 / 일괄 상태 변경)가 모두 같은 기준을
|
||||
* 쓰도록 한 곳에 둡니다 — 한쪽만 느슨하면 그 경로가 우회로가 됩니다.
|
||||
*
|
||||
* @param User $user 검사 대상 사용자
|
||||
*
|
||||
* @throws CannotDeleteSuperAdminException 슈퍼 관리자인 경우
|
||||
* @throws ValidationException 관리자 역할을 가진 경우
|
||||
*/
|
||||
private function assertWithdrawable(User $user): void
|
||||
{
|
||||
// 슈퍼 관리자는 탈퇴 불가
|
||||
if ($user->isSuperAdmin()) {
|
||||
throw new CannotDeleteSuperAdminException;
|
||||
}
|
||||
|
||||
// 관리자 역할을 가진 계정은 탈퇴 방지
|
||||
if ($user->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.withdraw_admin_forbidden')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴의 DB 변경만 수행합니다 (호출자가 연 트랜잭션 안에서 실행).
|
||||
*
|
||||
* 관리자가 상태 변경으로 탈퇴시키는 경로는 프로필 갱신과 탈퇴를 **한 트랜잭션**으로
|
||||
* 묶어야 한다 — 두 번에 나눠 커밋하면 탈퇴가 실패했을 때 프로필 변경만 남는다.
|
||||
* 그래서 DB 변경분을 이 메서드로 떼어 두 경로가 같은 코드를 공유한다.
|
||||
*
|
||||
* @param User $user 탈퇴 처리할 사용자 모델
|
||||
* @return bool 탈퇴 처리 성공 여부
|
||||
*/
|
||||
private function performWithdrawWrites(User $user): bool
|
||||
{
|
||||
// 훅 실행 (탈퇴 전)
|
||||
HookManager::doAction('core.user.before_withdraw', $user);
|
||||
|
||||
// 약관 동의 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->consents()->delete();
|
||||
|
||||
// 토큰 삭제 (로그아웃 처리)
|
||||
$user->tokens()->delete();
|
||||
|
||||
// 탈퇴 처리 (suffix 추가 및 상태 변경)
|
||||
return $user->withdraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 커밋 후 부수효과를 수행합니다.
|
||||
*
|
||||
* @param User $user 탈퇴 처리된 사용자 모델
|
||||
* @param Attachment|null $avatarAttachment 탈퇴 전 캡처한 아바타 첨부
|
||||
*/
|
||||
private function runWithdrawAfterEffects(User $user, $avatarAttachment): void
|
||||
{
|
||||
// 아바타 파일 삭제는 실패해도 탈퇴를 되돌리지 않는다
|
||||
// (고아 파일은 비파괴이고, 탈퇴를 되살리는 쪽이 훨씬 해롭다).
|
||||
if ($avatarAttachment) {
|
||||
try {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
} catch (Exception $e) {
|
||||
Log::warning('탈퇴 아바타 삭제 실패', [
|
||||
'user_id' => $user->id,
|
||||
'attachment_id' => $avatarAttachment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 훅 실행 (탈퇴 후)
|
||||
HookManager::doAction('core.user.after_withdraw', $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자를 탈퇴 처리합니다.
|
||||
*
|
||||
@@ -235,39 +359,19 @@ class UserService
|
||||
public function withdrawUser(User $user): bool
|
||||
{
|
||||
try {
|
||||
// 슈퍼 관리자는 탈퇴 불가
|
||||
if ($user->isSuperAdmin()) {
|
||||
throw new CannotDeleteSuperAdminException;
|
||||
}
|
||||
// 슈퍼 관리자 / 관리자 탈퇴 차단 (전 탈퇴 경로 공통 기준)
|
||||
$this->assertWithdrawable($user);
|
||||
|
||||
// 관리자 역할을 가진 계정은 탈퇴 방지
|
||||
if ($user->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.withdraw_admin_forbidden')],
|
||||
]);
|
||||
}
|
||||
|
||||
// 훅 실행 (탈퇴 전)
|
||||
HookManager::doAction('core.user.before_withdraw', $user);
|
||||
|
||||
// 약관 동의 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->consents()->delete();
|
||||
|
||||
// 아바타 Attachment 삭제 (다형성 관계)
|
||||
// 아바타는 파일 삭제를 동반하므로 트랜잭션 밖(커밋 후)에서 처리한다 —
|
||||
// 트랜잭션 안에서 지우면 롤백되어도 파일은 돌아오지 않는다.
|
||||
$avatarAttachment = $user->avatarAttachment;
|
||||
if ($avatarAttachment) {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
}
|
||||
|
||||
// 토큰 삭제 (로그아웃 처리)
|
||||
$user->tokens()->delete();
|
||||
// DB 변경은 전부 성공하거나 전부 취소된다 (공개이슈 #112).
|
||||
// 마지막 단계인 withdraw() 가 실패하면 동의 이력·토큰 삭제도 함께 되돌아간다.
|
||||
$result = DB::transaction(fn () => $this->performWithdrawWrites($user));
|
||||
|
||||
// 탈퇴 처리 (suffix 추가 및 상태 변경)
|
||||
$result = $user->withdraw();
|
||||
|
||||
// 훅 실행 (탈퇴 후)
|
||||
if ($result) {
|
||||
HookManager::doAction('core.user.after_withdraw', $user);
|
||||
$this->runWithdrawAfterEffects($user, $avatarAttachment);
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -310,27 +414,42 @@ class UserService
|
||||
// 삭제 전 사용자 데이터 보관 (after_delete 훅에서 사용)
|
||||
$userData = $user->only(['id', 'uuid', 'name', 'email']);
|
||||
|
||||
// Before 훅
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
// 아바타는 파일 삭제를 동반하므로 커밋 후에 처리한다 (롤백되어도 파일은 안 돌아온다).
|
||||
$avatarAttachment = $user->avatarAttachment;
|
||||
|
||||
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->roles()->detach();
|
||||
// 마지막 delete 가 FK 등으로 실패하면 역할·동의·토큰만 사라지고 계정은 남는
|
||||
// 껍데기 활성 계정이 된다 (실회귀 이력 존재). 전 단계를 하나로 묶는다.
|
||||
$result = DB::transaction(function () use ($user) {
|
||||
// Before 훅
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
|
||||
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
|
||||
$user->roles()->detach();
|
||||
|
||||
// 약관 동의 이력 삭제
|
||||
$user->consents()->delete();
|
||||
|
||||
// API 토큰 삭제
|
||||
$user->tokens()->delete();
|
||||
|
||||
return $this->userRepository->delete($user);
|
||||
});
|
||||
|
||||
// 커밋 성공 후 부수효과
|
||||
$user->flushPermissionCaches();
|
||||
|
||||
// 약관 동의 이력 삭제
|
||||
$user->consents()->delete();
|
||||
|
||||
// API 토큰 삭제
|
||||
$user->tokens()->delete();
|
||||
|
||||
// 아바타 Attachment 삭제 (다형성 관계)
|
||||
$avatarAttachment = $user->avatarAttachment;
|
||||
if ($avatarAttachment) {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
try {
|
||||
$this->attachmentService->delete($avatarAttachment->id);
|
||||
} catch (Exception $e) {
|
||||
Log::warning('사용자 삭제 아바타 정리 실패', [
|
||||
'user_id' => $userData['id'] ?? null,
|
||||
'attachment_id' => $avatarAttachment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->userRepository->delete($user);
|
||||
|
||||
// After 훅: 삭제된 사용자 데이터 전달
|
||||
HookManager::doAction('core.user.after_delete', $userData);
|
||||
|
||||
@@ -579,6 +698,12 @@ class UserService
|
||||
|
||||
$statusEnum = UserStatus::from($status);
|
||||
|
||||
// 일괄 '탈퇴'는 건별 정식 탈퇴로 전환한다 — 상태 컬럼만 바꾸면 익명화와
|
||||
// before/after_withdraw 훅이 통째로 생략되어, 본인 탈퇴와 결과가 달라진다.
|
||||
if ($statusEnum === UserStatus::Withdrawn) {
|
||||
return $this->bulkWithdraw($uuids, $status);
|
||||
}
|
||||
|
||||
// UUID → 정수 ID 변환 (내부 쿼리용)
|
||||
$userIds = $this->userRepository->getIdsByUuids($uuids);
|
||||
|
||||
@@ -588,7 +713,8 @@ class UserService
|
||||
$updateData = ['status' => $statusEnum->value];
|
||||
$updateData = match ($statusEnum) {
|
||||
UserStatus::Blocked => array_merge($updateData, ['blocked_at' => now()]),
|
||||
UserStatus::Withdrawn => array_merge($updateData, ['withdrawn_at' => now()]),
|
||||
// 탈퇴는 위에서 건별 withdrawUser() 로 분기하므로 여기로는 도달하지 않는다.
|
||||
UserStatus::Withdrawn => $updateData,
|
||||
UserStatus::Active => array_merge($updateData, ['blocked_at' => null, 'withdrawn_at' => null]),
|
||||
UserStatus::Inactive => $updateData,
|
||||
};
|
||||
@@ -608,6 +734,83 @@ class UserService
|
||||
|
||||
return [
|
||||
'updated_count' => $updatedCount,
|
||||
'failed_count' => 0,
|
||||
'failed_reasons' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 실패 예외를 운영자에게 보여줄 한 줄 사유로 바꿉니다.
|
||||
*
|
||||
* @param Exception $e 탈퇴 시도에서 발생한 예외
|
||||
* @return string 운영자에게 보여줄 실패 사유
|
||||
*/
|
||||
private function describeWithdrawFailure(Exception $e): string
|
||||
{
|
||||
if ($e instanceof ValidationException) {
|
||||
$first = collect($e->errors())->flatten()->first();
|
||||
|
||||
if (is_string($first) && $first !== '') {
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
|
||||
if ($e instanceof CannotDeleteSuperAdminException) {
|
||||
return __('exceptions.cannot_delete_super_admin');
|
||||
}
|
||||
|
||||
// 알 수 없는 예외의 원문 메시지는 사유로 쓰지 않는다 — 내부 사정(SQL·경로·클래스명)이
|
||||
// 운영자 화면으로 새고, 화면 문구 조립에서 파라미터 구분자로 쓰이는 문자(`|` `&` `=`)가
|
||||
// 섞이면 안내가 잘린다. 사유는 언어 파일이 소유하는 문구로만 구성한다.
|
||||
return __('user.withdraw_failed_unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 탈퇴를 건별 정식 탈퇴로 수행합니다.
|
||||
*
|
||||
* @param array<int, string> $uuids 대상 사용자 UUID 배열
|
||||
* @param string $status 요청 상태값 (훅 전달용)
|
||||
* @return array{updated_count: int, failed_count: int, failed_reasons: array<int, string>} 처리 결과
|
||||
*/
|
||||
private function bulkWithdraw(array $uuids, string $status): array
|
||||
{
|
||||
$updatedCount = 0;
|
||||
$failedCount = 0;
|
||||
$failedReasons = [];
|
||||
|
||||
foreach ($uuids as $uuid) {
|
||||
$user = $this->userRepository->findByUuid($uuid);
|
||||
|
||||
if (! $user) {
|
||||
$failedCount++;
|
||||
$failedReasons[] = __('user.not_found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->withdrawUser($user);
|
||||
$updatedCount++;
|
||||
} catch (Exception $e) {
|
||||
$failedCount++;
|
||||
$failedReasons[] = $this->describeWithdrawFailure($e);
|
||||
|
||||
Log::warning('일괄 탈퇴 건 실패', [
|
||||
'uuid' => $uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// after_bulk_update 훅 실행
|
||||
HookManager::doAction('sirsoft-core.user.after_bulk_update', $uuids, $status, $updatedCount);
|
||||
|
||||
// 사유를 함께 돌려주지 않으면 화면은 "몇 건 실패" 까지만 말할 수 있다 —
|
||||
// 운영자는 왜 안 됐는지 알 방법이 없어 같은 조작을 반복하게 된다.
|
||||
return [
|
||||
'updated_count' => $updatedCount,
|
||||
'failed_count' => $failedCount,
|
||||
'failed_reasons' => array_values(array_unique($failedReasons)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user