feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소

공개 (@Tuwasduliebst 제보) 대응.

- ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰,
 filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인,
 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본
 설정 백필 upgrade step
- board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI,
 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정
- ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정
- page: 임시 첨부 GC
- 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시
 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자
 메뉴 설치·업데이트 자동 동기화(F1·F7)
- 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정
 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서,
 hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
This commit is contained in:
HeuJung committed 2026-08-17 02:44:54 +09:00
1 parent 1d895a6428
commit cfc2caba1b
170 files changed
+11142 -209

No files matched your search

@@ -0,0 +1,123 @@
<?php
namespace App\Console\Commands;
use App\Services\AttachmentService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 소유자 없는 고아 첨부파일 정리 커맨드
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자 없이 먼저 만들어지므로, 폼을 저장하지 않고
* 이탈하면 파일과 기록이 그대로 남습니다. 그 잔존물을 회수합니다.
*
* 사용자 파일을 실제로 파기하므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄 호출
* (`--scheduled`)은 설정을 false 폴백으로 확인한 뒤에만 수행합니다.
*
* @example php artisan attachments:prune-orphans --dry-run
* @example php artisan attachments:prune-orphans --days=7 --limit=100
*/
class PruneOrphanAttachmentsCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'attachments:prune-orphans
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--days= : 보존기간(일) 재정의 — 미지정 시 환경설정값}
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '소유자 없이 방치된 고아 첨부파일을 정리합니다.';
/**
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
protected AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
if ($this->option('scheduled') && ! $this->isCleanupEnabled()) {
$this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)');
return Command::SUCCESS;
}
$days = $this->resolveRetentionDays();
if ($days < 1) {
$this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
return Command::SUCCESS;
}
$limit = max(1, (int) $this->option('limit'));
$isDryRun = (bool) $this->option('dry-run');
$result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun);
if ($isDryRun) {
$this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건");
return Command::SUCCESS;
}
$this->info(sprintf(
'보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
$days,
$result['scanned'],
$result['deleted'],
$result['failed'],
));
Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [
'days' => $days,
'limit' => $limit,
] + $result);
return Command::SUCCESS;
}
/**
* 자동 정리 토글을 false 폴백으로 조회합니다.
*
* @return bool 자동 정리 활성 여부
*/
private function isCleanupEnabled(): bool
{
return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false);
}
/**
* 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일).
*
* @return int 보존기간(일)
*/
private function resolveRetentionDays(): int
{
$option = $this->option('days');
if ($option !== null && $option !== '') {
return (int) $option;
}
return (int) g7_core_settings('upload.orphan_retention_days', 30);
}
}
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
/**
* 첨부파일 Repository 인터페이스
@@ -18,6 +19,17 @@ interface AttachmentRepositoryInterface
*/
public function findById(int $id): ?Attachment;
/**
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
*
* 영구 삭제(파일+forceDelete) 경로 전용이다. 소프트삭제된 행은 기본 조회에서 빠지므로,
* 이 메서드 없이 영구 삭제를 시도하면 대상을 찾지 못해 매 회차 실패만 누적된다.
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findByIdWithTrashed(int $id): ?Attachment;
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -145,4 +157,20 @@ interface AttachmentRepositoryInterface
* @param string $collection 컬렉션명
*/
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void;
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자(attachmentable) 없이 먼저 생성되므로,
* 폼을 저장하지 않고 이탈하면 그대로 남습니다. 그 회수 대상을 찾습니다.
*
* 확장이 소유한 첨부(source_identifier 보유)는 그 확장의 라이프사이클 소관이므로 제외하고,
* 현역으로 쓰이는 첨부 ID 는 호출자가 넘겨 보호합니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등)
* @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함)
*/
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection;
}
+2 -1
View File
@@ -19,7 +19,8 @@ use ReflectionClass;
* getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다.
* getName(), getVersion(), getDescription()은 plugin.json에서 자동 파싱됩니다.
*
* 참고: 플러그인은 모듈과 달리 관리자 메뉴(getAdminMenus)를 추가할 수 없습니다.
* 참고: 플러그인도 관리자 메뉴(getAdminMenus)를 선언할 수 있습니다. 설치·업데이트·활성화가
* 공통으로 지나는 선언형 산출물 동기화에서 PluginManager 가 자동으로 반영합니다.
*/
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
{
+56 -6
View File
@@ -21,6 +21,7 @@ use App\Exceptions\LayoutIncludeException;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
@@ -449,6 +450,9 @@ class PluginManager implements PluginManagerInterface
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
$this->createPluginMenus($plugin);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncPluginIdentityPolicies($plugin);
@@ -2003,10 +2007,54 @@ class PluginManager implements PluginManagerInterface
);
}
/**
* 플러그인이 선언한 관리자 메뉴를 동기화합니다.
*
* 플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 를 선언할 수 있습니다.
* 이 동기화가 설치·활성화 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을
* 업데이트해 새 화면을 받아도 메뉴가 만들어지지 않아 그 화면은 주소를 직접 입력해야만
* 닿을 수 있습니다. 그래서 선언형 산출물 동기화(설치·업데이트 공통 경로)에 포함합니다.
*
* helper 의 upsert 패턴을 그대로 쓰므로 재호출은 무해하며(멱등) 운영자 커스터마이징
* (user_overrides) 도 보존됩니다.
*
* @param PluginInterface $plugin 메뉴를 동기화할 플러그인 인스턴스
*/
protected function createPluginMenus(PluginInterface $plugin): void
{
if (! method_exists($plugin, 'getAdminMenus')) {
return;
}
$menus = $plugin->getAdminMenus();
if (empty($menus)) {
return;
}
// 활성 언어팩이 admin_menus 다국어 필드(name 등)에 추가 locale 을 주입할 수 있도록
// 모듈과 동일한 필터 훅 규약을 적용한다 (LanguagePackSeedInjector 가 결선).
$menus = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.admin_menus.translations",
$menus,
);
$helper = app(ExtensionMenuSyncHelper::class);
foreach ($menus as $menuData) {
$helper->syncMenuRecursive(
$menuData,
ExtensionOwnerType::Plugin,
$plugin->getIdentifier(),
);
}
}
/**
* 플러그인 정의 기준으로 stale 권한·역할을 정리합니다 (완전 동기화 원칙).
*
* 플러그인은 메뉴(getAdminMenus) 를 지원하지 않으므로 권한·역할만 대상.
* 메뉴 stale 정리는 비활성화·삭제 시 플러그인 자신이 `cleanupStaleMenus` 로 수행하므로
* 여기서는 권한·역할만 대상으로 한다.
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
*/
protected function cleanupStalePluginEntries(PluginInterface $plugin): void
@@ -2065,14 +2113,15 @@ class PluginManager implements PluginManagerInterface
* 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정
* (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
*
* 동기화 대상 (플러그인은 메뉴 미지원 — getAdminMenus 부재):
* 동기화 대상:
* 1. 역할 (`getRoles`)
* 2. 권한 (`getPermissions`)
* 3. 역할-권한 매핑 (`getRolePermissions`)
* 4. stale cleanup (현재 선언에 없는 기존 레코드 제거)
* 5. IDV 정책 (`getIdentityPolicies`)
* 6. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 7. 알림 정의/템플릿 (`getNotificationDefinitions`)
* 4. 관리자 메뉴 (`getAdminMenus`)
* 5. stale cleanup (현재 선언에 없는 기존 레코드 제거)
* 6. IDV 정책 (`getIdentityPolicies`)
* 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
*
* 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
* 무해 (멱등).
@@ -2084,6 +2133,7 @@ class PluginManager implements PluginManagerInterface
$this->createPluginRoles($plugin);
$this->createPluginPermissions($plugin);
$this->assignPermissionsToRoles($plugin);
$this->createPluginMenus($plugin);
$this->cleanupStalePluginEntries($plugin);
$this->syncPluginIdentityPolicies($plugin);
$this->syncPluginIdentityMessages($plugin);
@@ -188,23 +188,21 @@ class SettingsController extends AdminBaseController
}
/**
* 데이터베이스를 백업합니다.
* 데이터베이스 백업 — 아직 제공하지 않는 기능임을 알립니다.
*
* @return JsonResponse 백업 결과 JSON 응답
* 코어에는 DB 덤프 수단이 없어 이 엔드포인트는 구현된 적이 없다.
* 종전에는 존재하지 않는 `SettingsService::backupDatabase()` 를 호출했고,
* PHP 가 던지는 `Error` 는 `catch (\Exception)` 에 걸리지 않아 그대로 500 이 되면서
* 내부 메서드 이름까지 응답에 실려 나갔다 (공개 #115 부록 B3).
*
* 기능 부재는 서버 고장이 아니므로 501(Not Implemented)로 답한다.
* 설정 파일 백업은 `POST /api/admin/settings/backup` 이 담당한다.
*
* @return JsonResponse 미제공 안내 JSON 응답 (501)
*/
public function backupDatabase(): JsonResponse
{
try {
$result = $this->settingsService->backupDatabase();
if ($result) {
return $this->success('settings.backup_success');
} else {
return $this->error('settings.backup_failed');
}
} catch (\Exception $e) {
return $this->error('settings.backup_error', 500, $e->getMessage());
}
return $this->error('settings.database_backup_unavailable', 501);
}
/**
@@ -162,4 +162,40 @@ class UpdatePluginSettingsRequest extends FormRequest
'min' => __('validation.min'),
];
}
/**
* 검증 오류에 쓸 필드 표시명을 반환합니다.
*
* 설정 스키마의 `label` 을 그대로 씁니다. 이 값이 없으면 Laravel 이 원시 키를 풀어쓴
* 이름("unused image retention days")을 그대로 노출하는데, 같은 화면의 입력 라벨은
* 한국어인 상태라 운영자에게는 어느 항목을 고치라는 말인지 닿지 않습니다.
*
* @return array<string, string> 필드 => 표시명
*/
public function attributes(): array
{
$plugin = app(PluginManager::class)->getPlugin($this->route('identifier'));
if (! $plugin) {
return [];
}
$locale = app()->getLocale();
$fallback = config('app.fallback_locale', 'en');
$attributes = [];
foreach ($plugin->getSettingsSchema() as $field => $config) {
$label = $config['label'] ?? null;
if (is_array($label)) {
$label = $label[$locale] ?? $label[$fallback] ?? reset($label);
}
if (is_string($label) && $label !== '') {
$attributes[$field] = $label;
}
}
return $attributes;
}
}
@@ -226,6 +226,9 @@ class SaveSettingsRequest extends FormRequest
'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)],
'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)],
'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)],
// 고아 첨부 정리 — 사용자 파일을 파기하므로 보존기간 하한을 서버가 강제한다.
'upload.orphan_cleanup_enabled' => ['nullable', 'boolean'],
'upload.orphan_retention_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_orphan_retention_days_min', 1), 'max:'.config('core.settings_limits.upload_orphan_retention_days_max', 3650)],
// SEO 설정
'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'],
@@ -678,6 +681,10 @@ class SaveSettingsRequest extends FormRequest
'upload.image_quality.integer' => __('validation.settings.image_quality_integer'),
'upload.image_quality.min' => __('validation.settings.image_quality_min'),
'upload.image_quality.max' => __('validation.settings.image_quality_max'),
'upload.orphan_cleanup_enabled.boolean' => __('validation.settings.orphan_cleanup_enabled_boolean'),
'upload.orphan_retention_days.integer' => __('validation.settings.orphan_retention_days_integer'),
'upload.orphan_retention_days.min' => __('validation.settings.orphan_retention_days_min'),
'upload.orphan_retention_days.max' => __('validation.settings.orphan_retention_days_max'),
// SEO 설정
'seo.meta_title_suffix.max' => __('validation.settings.meta_title_suffix_max'),
@@ -914,6 +921,8 @@ class SaveSettingsRequest extends FormRequest
'upload.image_max_width' => __('validation.attributes.image_max_width'),
'upload.image_max_height' => __('validation.attributes.image_max_height'),
'upload.image_quality' => __('validation.attributes.image_quality'),
'upload.orphan_cleanup_enabled' => __('validation.attributes.orphan_cleanup_enabled'),
'upload.orphan_retention_days' => __('validation.attributes.orphan_retention_days'),
// seo
'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'),
'seo.meta_description' => __('validation.attributes.meta_description'),
+40
View File
@@ -5,6 +5,7 @@ namespace App\Repositories;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
@@ -23,6 +24,17 @@ class AttachmentRepository implements AttachmentRepositoryInterface
return Attachment::find($id);
}
/**
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findByIdWithTrashed(int $id): ?Attachment
{
return Attachment::withTrashed()->find($id);
}
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -283,4 +295,32 @@ class AttachmentRepository implements AttachmentRepositoryInterface
}
});
}
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* @param Carbon $threshold 기준 시각
* @param int $limit 최대 조회 건수
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록
* @return Collection 고아 첨부 후보
*/
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection
{
$query = Attachment::withTrashed()
->whereNull('attachmentable_type')
->whereNull('attachmentable_id')
// 확장이 소유한 첨부는 그 확장의 라이프사이클 소관이므로 코어 GC 대상이 아니다.
->whereNull('source_identifier')
->where('created_at', '<', $threshold);
if ($protectedIds !== []) {
$query->whereNotIn('id', $protectedIds);
}
return $query
->orderBy('created_at')
->orderBy('id')
->limit($limit)
->get(['id', 'disk', 'path', 'collection', 'created_at']);
}
}
+81 -2
View File
@@ -165,14 +165,93 @@ class AttachmentService
}
/**
* 첨부파일 삭제
* 소유자 없이 방치된 고아 첨부를 정리합니다.
*
* 폼 저장 전에 즉시 업로드되는 첨부(FileUploader 등)는 소유자 없이 먼저 만들어지므로,
* 폼을 저장하지 않고 이탈하면 파일과 기록이 그대로 남습니다.
*
* 오탐을 막기 위해 아래 조건을 모두 만족하는 행만 대상으로 삼습니다.
*
* 1. 소유자(attachmentable_type/id)가 없다
* 2. 업로드 후 보존기간이 지났다 (폼 작성 중 유예)
* 3. 확장 소유가 아니다 (source_identifier 없음 — 확장 라이프사이클 소관)
* 4. 현역으로 쓰이는 첨부가 아니다 (현재 사이트 로고 등)
*
* 4번 보호 조건은 이 메서드가 직접 해석합니다. 호출자가 넘기는 값으로 두면 그 인자를
* 빠뜨린 호출 하나가 운영 중인 로고를 파기하는데, 나머지 세 조건은 조회의 불변식이라
* 보호 조건만 규약으로 남는 비대칭이 생깁니다.
*
* 삭제는 단건 삭제 경로(delete)를 그대로 재사용하므로 파일 삭제·훅·로그가 동일합니다.
*
* @param int $days 보존기간(일)
* @param int $limit 한 회차에 처리할 최대 건수
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
public function pruneOrphans(int $days, int $limit, bool $dryRun = false): array
{
$threshold = now()->subDays($days);
$candidates = $this->repository->findOrphanCandidates(
$threshold,
$limit,
$this->protectedAttachmentIds(),
);
$result = ['scanned' => $candidates->count(), 'deleted' => 0, 'failed' => 0];
if ($dryRun) {
return $result;
}
foreach ($candidates as $candidate) {
$this->delete($candidate->id) ? $result['deleted']++ : $result['failed']++;
}
return $result;
}
/**
* 고아 정리에서 보호할 첨부 ID 목록을 반환합니다.
*
* 사이트 로고는 소유자(attachmentable) 없이 설정값이 직접 참조하는 첨부라 판정식만으로는
* 고아와 구분되지 않습니다. 현역으로 설정에 실린 ID 를 명시적으로 제외합니다.
*
* @return array<int, int> 보호할 첨부 ID 목록
*/
private function protectedAttachmentIds(): array
{
$siteLogo = g7_core_settings('general.site_logo', []);
if (! is_array($siteLogo)) {
return [];
}
$ids = [];
foreach ($siteLogo as $item) {
$id = is_array($item) ? ($item['id'] ?? null) : $item;
if (is_numeric($id)) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* 첨부파일 삭제 (파일 + 기록 영구 삭제)
*
* 대상 조회는 소프트삭제된 행까지 포함한다. 이 메서드가 수행하는 것은 forceDelete 이므로
* 이미 소프트삭제된 행도 정당한 대상이고, 기본 조회로 찾으면 고아 정리가 그 행을 영원히
* 회수하지 못한 채 실패만 반복한다.
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
$attachment = $this->repository->findById($id);
$attachment = $this->repository->findByIdWithTrashed($id);
if (! $attachment) {
return false;
+118 -9
View File
@@ -29,7 +29,8 @@ class SettingsService
public function __construct(
private ConfigRepositoryInterface $configRepository,
private AttachmentRepositoryInterface $attachmentRepository,
private CacheInterface $cache
private CacheInterface $cache,
private AttachmentService $attachmentService
) {}
/**
@@ -515,9 +516,18 @@ class SettingsService
return $result;
}
// general 탭인 경우 site_logo 첨부파일 연결
if ($tab === 'general') {
$tabSettings['site_logo'] = $this->collectSiteLogoIds();
// general 탭인 경우 site_logo 첨부파일 연결.
// site_logo 를 제출하지 않은 저장(다른 필드만 변경)은 기존 저장값을 그대로 둔다 —
// 이때 컬렉션을 다시 훑으면 미참조 첨부가 설정으로 딸려 들어온다.
$removedSiteLogoIds = [];
if ($tab === 'general' && is_array($tabSettings['site_logo'] ?? null)) {
// 파기 대상 판정은 저장 **전에** 한다 — 저장 후에는 직전 저장값을 알 수 없다.
// 실제 파기는 저장이 성공한 뒤에 수행한다: 저장이 실패했는데 파일만 사라지면
// 설정에는 이미 없는 첨부 id 가 남아 로고가 깨진다.
$removedSiteLogoIds = $this->resolveRemovedSiteLogoIds($tabSettings['site_logo']);
$tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']);
}
// 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지)
@@ -536,6 +546,9 @@ class SettingsService
if ($result) {
$this->invalidateSettingsCache();
// 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조).
$this->purgeSiteLogoAttachments($removedSiteLogoIds);
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
// 않아 브라우저 자가 복구가 닿지 않는다 → 캐시를 비워 재생성시킨다.
@@ -684,15 +697,111 @@ class SettingsService
}
/**
* site_logo 컬렉션의 첨부파일 ID 목록을 수집합니다.
* 저장할 사이트 로고 첨부 ID 목록을 결정합니다.
*
* @return array<int> 첨부파일 ID 배열
* 기준은 **이번 저장 요청이 제출한 목록**입니다. 컬렉션 전체를 다시 훑으면, 저장에 실패했거나
* 작성 중 이탈해 남은 미참조 첨부까지 설정에 다시 편입되어(운영자가 올린 적 없는 로고가
* 되살아나는) 누적이 발생합니다.
*
* 제출값에 있더라도 실제로 존재하지 않는 첨부(다른 경로로 이미 삭제된 id)는 걸러냅니다.
*
* @param array<int, mixed> $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열)
* @return array<int, int> 저장할 첨부파일 ID 배열
*/
private function collectSiteLogoIds(): array
private function resolveSiteLogoIds(array $submitted): array
{
$attachments = $this->attachmentRepository->getByCollection('site_logo');
$submittedIds = $this->extractAttachmentIds($submitted);
return $attachments->pluck('id')->toArray();
if ($submittedIds === []) {
return [];
}
$existingIds = $this->attachmentRepository->getByCollection('site_logo')
->pluck('id')
->all();
return array_values(array_intersect($submittedIds, $existingIds));
}
/**
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다.
*
* 판정 기준은 **직전 저장값**입니다. 직전에 저장돼 있었는데 이번 제출에서 빠진 id 만
* 운영자가 명시적으로 뺀 것이고, 직전 저장값에도 없던 id 는 이번에 새로 올라온 첨부입니다.
* 그래서 이 판정은 저장으로 값이 덮이기 **전에** 수행해야 합니다.
*
* 저장할 목록 자체는 제출값이 정합니다(resolveSiteLogoIds) — 컬렉션 전체를 훑으면 저장에
* 실패했거나 이탈로 남은 미참조 첨부가 설정에 되살아납니다.
*
* @param mixed $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열, 미제출이면 null)
* @return array<int, int> 파기 대상 첨부 ID 목록
*/
private function resolveRemovedSiteLogoIds(mixed $submitted): array
{
// site_logo 를 아예 제출하지 않은 저장(다른 필드만 변경)은 판정 대상이 아니다.
if (! is_array($submitted)) {
return [];
}
$previousIds = $this->extractAttachmentIds(
$this->configRepository->getCategory('general')['site_logo'] ?? []
);
if ($previousIds === []) {
return [];
}
$keptIds = $this->extractAttachmentIds($submitted);
return array_values(array_diff($previousIds, $keptIds));
}
/**
* 제거가 확정된 사이트 로고 첨부를 파일까지 파기합니다.
*
* 설정 저장이 성공한 뒤에만 호출합니다 — 저장이 실패했는데 파일이 먼저 사라지면 설정에는
* 이미 없는 첨부 id 가 남아 로고가 깨집니다.
*
* @param array<int, int> $removedIds 파기 대상 첨부 ID 목록
*/
private function purgeSiteLogoAttachments(array $removedIds): void
{
if ($removedIds === []) {
return;
}
foreach ($removedIds as $removedId) {
$this->attachmentService->delete($removedId);
}
Log::info('사이트 로고 첨부 제거', ['attachment_ids' => $removedIds]);
}
/**
* 첨부 목록 값에서 첨부 ID 만 추출합니다.
*
* 저장값은 ID 배열이지만 화면 제출값은 첨부 객체 배열이라 두 형태를 모두 받습니다.
*
* @param mixed $value 첨부 목록 값
* @return array<int, int> 첨부 ID 목록
*/
private function extractAttachmentIds(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$ids = [];
foreach ($value as $item) {
$id = is_array($item) ? ($item['id'] ?? null) : $item;
if (is_numeric($id)) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**