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

+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;