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
2026-08-17 02:44:54 +09:00
parent 1d895a6428
commit cfc2caba1b
170 changed files with 11142 additions and 209 deletions
+4
View File
@@ -28,6 +28,7 @@
- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다.
- 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다.
- 폼을 저장하지 않고 나가서 소유자 없이 남은 첨부파일을 자동으로 정리할 수 있습니다. 환경설정 > 업로드에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 고아 첨부의 파일과 기록을 매일 삭제합니다. 사이트 로고처럼 현재 사용 중인 첨부와 확장이 관리하는 첨부는 대상에서 제외됩니다.
- 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다.
### Changed
@@ -51,6 +52,9 @@
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다.
- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다.
- 플러그인이 선언한 관리자 메뉴가 설치·업데이트 시 자동으로 만들어지도록 수정했습니다. 종전에는 플러그인이 활성화 처리에서 직접 만든 경우에만 메뉴가 생겨서, 이미 사용 중인 사이트가 플러그인을 업데이트해 새 화면을 받아도 메뉴가 나타나지 않고 주소를 직접 입력해야만 닿을 수 있었습니다. 이제 모듈과 동일하게 처리됩니다.
- 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
@@ -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));
}
/**
+2
View File
@@ -25,6 +25,8 @@ return [
'upload_image_max_height_max' => 10000,
'upload_image_quality_min' => 1,
'upload_image_quality_max' => 100,
'upload_orphan_retention_days_min' => 1,
'upload_orphan_retention_days_max' => 3650,
// SEO
'seo_og_image_default_width_min' => 0,
+6 -2
View File
@@ -49,7 +49,9 @@
"allowed_extensions": ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip"],
"image_max_width": 2000,
"image_max_height": 2000,
"image_quality": 85
"image_quality": 85,
"orphan_cleanup_enabled": false,
"orphan_retention_days": 30
},
"seo": {
"meta_title_suffix": "",
@@ -202,7 +204,9 @@
"allowed_extensions_formatted": { "type": "string", "sensitive": false, "computed": true, "source": "allowed_extensions", "transform": "format_extensions" },
"image_max_width": { "type": "integer", "sensitive": false },
"image_max_height": { "type": "integer", "sensitive": false },
"image_quality": { "type": "integer", "sensitive": false, "expose": false }
"image_quality": { "type": "integer", "sensitive": false, "expose": false },
"orphan_cleanup_enabled": { "type": "boolean", "sensitive": false, "expose": false },
"orphan_retention_days": { "type": "integer", "sensitive": false, "expose": false }
}
},
"seo": {
+9 -6
View File
@@ -202,6 +202,8 @@ HTTP/1.1 200
| upload.image_max_width | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 너비 (px) |
| upload.image_max_height | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 높이 (px) |
| upload.image_quality | body | integer | 아니오 | min 1, max 100 | 이미지 리사이즈 시 압축 품질 (1~100) |
| upload.orphan_cleanup_enabled | body | boolean | 아니오 | — | 고아 첨부(소유자 없이 남은 첨부) 자동 정리 활성화 여부. **기본 false** — 사용자 파일을 실제로 파기하므로 운영자가 직접 켜야 동작하며, 예약 작업(`attachments:prune-orphans --scheduled`)이 이 값을 false 폴백으로 재확인한다 |
| upload.orphan_retention_days | body | integer | 아니오 | min 1, max 3650 | 고아 첨부 보존기간(일, 기본 30). 폼 작성 중 이탈을 감안한 유예 기간이다. 하한·상한은 `config('core.settings_limits.upload_orphan_retention_days_*')` 가 정하며 화면 입력도 같은 값을 읽는다 |
| seo.meta_title_suffix | body | string | 아니오 | max 100 | 모든 페이지 SEO 제목 뒤에 붙는 접미 문구 |
| seo.meta_description | body | string | 아니오 | max 160 | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) |
| seo.meta_keywords | body | string | 아니오 | max 255 | SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분) |
@@ -654,14 +656,14 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.backup_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._
_이 엔드포인트는 성공 응답을 반환하지 않습니다. 데이터베이스 백업 기능이 아직 제공되지 않아 항상 501 로 응답합니다._
**응답 예시**
```json
{
"success": true,
"message": "데이터베이스 백업이 성공적으로 시작되었습니다.",
"success": false,
"message": "데이터베이스 백업 기능은 아직 제공하지 않습니다. 설정 백업은 설정 백업 기능을 이용해 주세요.",
"data": null
}
```
@@ -670,16 +672,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 400 | Bad Request | 백업이 수행되지 않은 경우 (`settings.backup_failed`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 500 | Internal Server Error | 백업 처리 중 예외가 발생한 경우 (`settings.backup_error`) |
| 501 | Not Implemented | 항상 (데이터베이스 백업 미제공, `settings.database_backup_unavailable`) |
<!-- @generated:end -->
**설명**
데이터베이스를 백업합니다. SettingsService 에 위임하며, 성공/실패를 메시지로 반환합니다. 설정 백업(`POST /backup`)이 설정 파일만 다루는 것과 달리, 이 엔드포인트는 DB 데이터를 백업 대상으로 합니다.
**데이터베이스 백업은 아직 제공하지 않습니다.** 이 엔드포인트는 라우트만 존재하며 호출하면 항상 501(Not Implemented)로 응답합니다. 코어에는 데이터베이스 덤프 수단이 없어 구현된 적이 없으며, 관리자 화면에도 이 기능을 호출하는 지점이 없습니다.
설정 파일 백업이 필요하면 `POST /api/admin/settings/backup` 을 사용하십시오 — 그쪽은 정상 동작하며 생성된 백업 경로를 `data.backup_path` 로 돌려줍니다.
### POST /api/admin/settings/clear-cache
+42
View File
@@ -148,7 +148,14 @@ sirsoft-page.attachment.filter_upload_file # 페이지 첨부
sirsoft-ecommerce.product-image.filter_upload_file # 상품 이미지
sirsoft-ecommerce.category-image.filter_upload_file # 카테고리 이미지
sirsoft-ecommerce.review-image.filter_upload_file # 리뷰 이미지 (공개 #96)
sirsoft-ckeditor5.image.before_upload # 에디터 이미지
sirsoft-ckeditor5.image.filter_upload_file # 에디터 이미지
sirsoft-ckeditor5.image.after_upload # 에디터 이미지
# 업로드 잔존물 회수 — 어떤 콘텐츠에서도 쓰이지 않는 에디터 이미지의 참조 판정 대상 선언 (필터)
# 자기 콘텐츠를 가진 확장이 이 훅으로 테이블/컬럼을 등록하지 않으면, 그 확장에서만 쓰이는
# 이미지가 "미참조" 로 판정돼 자동 정리 대상이 된다.
sirsoft-ckeditor5.image.filter_reference_sources # 에디터 이미지 참조 소스
# FormRequest Validation Rules 훅 (Filter)
core.user.create_validation_rules
@@ -238,6 +245,41 @@ core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'fai
`core.settings.available_public_asset_drivers` 로 등록하는 디스크는 플러그인 ServiceProvider 에서 `filesystems.disks.{id}` 정의가 함께 있어야 하며, 그 정의에 `url` 키가 있어야 직접 URL 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다.
#### `sirsoft-ckeditor5.image.filter_reference_sources`
에디터 업로드 이미지가 "어디선가 쓰이고 있는지" 판정할 때 훑을 테이블/컬럼 목록을 확장합니다. 첫 인자인 소스 배열에 다음 구조의 항목을 append 합니다.
| 키 | 타입 | 값 |
| --- | --- | --- |
| `table` | string | 테이블명 — **프리픽스 제외 원시 이름** (`board_posts`) |
| `columns` | list\<string\> | 본문이 담긴 컬럼명 목록 (`['content']`) |
자기 콘텐츠에 에디터를 노출하는 확장은 이 훅을 반드시 구독해야 합니다. 등록하지 않으면 그 확장에서만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이 됩니다.
리스너는 테이블명 문자열만 덧붙이고 DB 에 접근하지 않습니다 — 실재 검증(테이블·컬럼 존재)과 조회는 플러그인이 수행하며, 존재하지 않는 선언은 경고만 남기고 건너뜁니다(한 확장의 잘못된 선언이 판정 전체를 멈추지 않습니다).
로그 사본 테이블(발송 이력·신고 스냅샷 등)은 등록하지 않습니다. 이들은 자체 보존기간으로 삭제되므로 참조 소스로 삼으면 "로그가 지워지는 순간 이미지가 고아가 되는" 역전이 생깁니다.
```php
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ckeditor5.image.filter_reference_sources' => [
'method' => 'addSources',
'priority' => 10,
'type' => 'filter',
],
];
}
public function addSources(array $sources): array
{
$sources[] = ['table' => 'my_documents', 'columns' => ['body']];
return $sources;
}
```
---
## 리스너 구현
@@ -16,6 +16,8 @@
- 설정 항목 단위 저장의 값 형식 안내 일본어 번역을 추가했습니다 (`validation.setting.value.*`) — 켜기/끄기·숫자 설정에 맞지 않는 값을 저장할 때의 안내가 일본어 로케일에서 표시됩니다.
- 슈퍼 관리자 계정·역할 수정 상한 및 권한 부여 상한 위반 시 표시되는 예외 메시지(`cannot_modify_super_admin`, `cannot_grant_unheld_permission`, `cannot_modify_protected_role`)의 일본어 번역을 추가했습니다.
- 레이아웃 저장 시 위험 표현식·외부 리소스 URL 거부 안내(`layout.dangerous_expression`, `layout.external_resource_url`)의 일본어 번역을 추가했습니다.
- 고아 첨부 정리 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`validation.settings.orphan_*`) — 환경설정 > 업로드의 자동 정리·보존기간을 잘못 저장할 때의 안내가 일본어 로케일에서 항목 이름과 함께 표시됩니다.
- 데이터베이스 백업 미제공 안내 문구의 일본어 번역을 추가했습니다 (`settings.database_backup_unavailable`).
### Changed
@@ -20,6 +20,7 @@ return [
'backup_success' => 'データベースバックアップが正常に開始されました。',
'backup_failed' => 'データベースバックアップに失敗しました。',
'backup_error' => 'データベースバックアップ中にエラーが発生しました。',
'database_backup_unavailable' => 'データベースバックアップ機能はまだ提供されていません。設定のバックアップは設定バックアップ機能をご利用ください。',
'backup_path_required' => 'バックアップパスを入力してください。',
'restore_success' => '設定が正常に復元されました。',
'restore_failed' => '設定の復元に失敗しました。',
@@ -912,7 +912,7 @@ return [
's3_endpoint_invalid' => '正しいS3エンドポイントURL形式ではありません。',
's3_endpoint_max' => 'S3エンドポイントURLは500字を超えることはできません。',
's3_use_path_style_boolean' => 'Path-styleアドレス使用の設定はtrueまたはfalseである必要があります。',
'driver_unusable' => "':driver' ドライバーはこのサーバーでは使用できません。:reason",
'driver_unusable' => '\':driver\' ドライバーはこのサーバーでは使用できません。:reason',
'cache_driver_required' => 'キャッシュドライバーを選択してください。',
'cache_driver_invalid' => '正しいキャッシュドライバーを選択してください。',
'redis_host_max' => 'Redisホストは255字を超えることはできません。',
@@ -994,6 +994,10 @@ return [
'pagination_max_page_integer' => 'ページ番号上限は数値である必要があります。',
'pagination_max_page_min' => 'ページ番号上限は:min以上である必要があります。(0 = 無制限)',
'pagination_max_page_max' => 'ページ番号上限は:maxを超えることはできません。',
'orphan_cleanup_enabled_boolean' => '孤立した添付ファイルの自動クリーンアップは有効化または無効化のいずれかである必要があります。',
'orphan_retention_days_integer' => '孤立した添付ファイルの保持期間は整数である必要があります。',
'orphan_retention_days_min' => '孤立した添付ファイルの保持期間は1日以上である必要があります。',
'orphan_retention_days_max' => '孤立した添付ファイルの保持期間は3650日を超えることはできません。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
@@ -1174,6 +1178,8 @@ return [
'log_level' => 'ログレベル',
'log_days' => 'ログ保持日数',
'public_asset_disk' => '公開アセットディスク',
'orphan_cleanup_enabled' => '孤立した添付ファイルの自動クリーンアップ',
'orphan_retention_days' => '孤立した添付ファイルの保持期間',
],
'layout_version' => [
'limit' => [
@@ -10,6 +10,7 @@
- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
- 비공개(비밀) 게시글 목록 마스킹 시 표시되는 제목 플레이스홀더(`secret_post_title`)의 일본어 번역을 추가했습니다.
- 첨부 영구 정리 설정의 라벨·도움말과 검증 항목명 일본어 번역을 추가했습니다 (`admin/settings.attachment_purge_*`, `validation.attributes.attachment_settings.*`) — 게시판 환경설정 > 기본 설정의 새 항목과 저장 시 오류 안내가 일본어 로케일에서 표시됩니다.
### Changed
@@ -349,6 +349,8 @@ return [
'override_values.max_file_size' => '最大ファイルサイズ',
'override_values.max_file_count' => '最大ファイル個数',
'override_values.new_display_hours' => '新規表示時間',
'attachment_settings.purge_enabled' => '削除添付の永久クリーンアップ',
'attachment_settings.purge_retention_days' => '削除添付の保持期間',
],
],
'blind' => [
@@ -60,7 +60,9 @@
"report_notification": "通報通知",
"report_notification_description": "通報受け付け時に通知を受け取る管理者と通知発送方式を設定します。",
"report_permissions": "通報管理権限",
"report_permissions_description": "通報リスト閲覧および通報処理(承認·却下·ブラインド)を実行できるロールを指定します。通報受け付け通知は「通報処理権限」を持つロール基準で発送されます。"
"report_permissions_description": "通報リスト閲覧および通報処理(承認·却下·ブラインド)を実行できるロールを指定します。通報受け付け通知は「通報処理権限」を持つロール基準で発送されます。",
"attachment_cleanup": "添付ファイルの整理",
"attachment_cleanup_description": "削除された添付ファイルの物理ファイルをいつ廃棄するかを設定します。投稿作成中にアップロードされたが保存されなかった一時的な添付は、この設定に関わらず毎日自動で整理されます。"
},
"fields": {
"type": "掲示板タイプ",
@@ -176,7 +178,11 @@
"max_file_size": "アップロード可能な最大ファイルサイズです (MB単位、{{min}}~{{max}})",
"max_file_count": "投稿あたり添付可能な最大ファイル数です ({{min}}~{{max}})",
"allowed_extensions": "許可するファイル拡張子を最低1つ以上入力してください"
}
},
"attachment_purge_enabled": "削除済み添付ファイルの永久削除",
"attachment_purge_enabled_hint": "デフォルトは無効です。運営者が有効にしたときにのみ毎日の自動整理が動作します。有効にすると、削除されてから保存期間が経過した添付ファイルとそのレコードが永久削除されます。サーバーで直接実行する整理コマンドはこの設定に左右されません。",
"attachment_purge_retention_days": "削除済み添付ファイル保存期間(日)",
"attachment_purge_retention_days_hint": "ゴミ箱から投稿を復元すると、この期間内の添付ファイルは一緒に復元されます。ゴミ箱の復元可能期間と同じに保つようにしてください。(1~3650 日)"
},
"options": {
"date_display_format": {
@@ -9,6 +9,10 @@
### Added
- 에디터 설정의 "공개 자산 디스크" 설정 문구 일본어 번역을 추가했습니다.
- 「에디터 업로드 이미지」 관리 화면의 문구 일본어 번역을 추가했습니다 (`admin.uploads.*`) — 목록 제목·열 이름·참조 상태 배지·필터·삭제 확인 창과 판정 범위 안내가 일본어 로케일에서 표시됩니다.
- 미사용 이미지 자동 정리 설정의 라벨·도움말 일본어 번역을 추가했습니다 (`settings.section_cleanup`, `settings.cleanup.*`).
- 업로드 이미지 삭제 시의 안내·오류 메시지 일본어 번역을 추가했습니다 (`messages.uploads.*`, `messages.cleanup.*`).
- 업로드 이미지 조회·삭제 권한의 이름과 설명 일본어 번역을 추가했습니다 — 역할 권한 화면에서 일본어 로케일로 표시됩니다.
## [1.0.0] - 2026-07-01
@@ -13,4 +13,15 @@ return [
'forbidden' => '画像アップロード権限がありません。',
'failed' => '画像アップロードに失敗しました。',
],
'uploads' => [
'not_found' => 'アップロード画像が見つかりません。',
'file_delete_failed' => '画像ファイルの削除に失敗しました。しばらく後に再度お試しください。',
'ids_required' => '削除する画像を選択してください。',
'deleted' => '画像を削除しました。',
'bulk_deleted' => '選択した画像 :deleted件を削除しました。',
'ids_invalid' => '選択した画像の中に存在しないアイテムがあります。リストをリフレッシュした後、再度選択してください。',
],
'cleanup' => [
'retention_disabled' => '保存期間が1日未満に設定されているため、クリーンアップを実行しませんでした。',
],
];
@@ -1,7 +1,8 @@
{
"editor": {
"data_source": {
"settings": "エディター設定"
"settings": "エディター設定",
"ckeditor5Uploads": "アップロード画像一覧"
}
},
"settings": {
@@ -46,6 +47,60 @@
"step3": "画像アップロードを有効化するとエディターで画像を直接貼り付けたりアップロードできます。アップロードされた画像はサーバーに保存されます。",
"step4": "設定変更後に保存すると即座にすべてのエディターに反映されます。",
"step5": "プラグインを無効化するとエディターは基本 HtmlEditor に、コンテンツ表示は基本 HtmlContent に自動復元されます。"
},
"section_cleanup": "未使用画像の整理",
"cleanup": {
"toggle_label": "未使用画像の自動整理",
"toggle_hint": "デフォルトはオフです。有効にするには運営者が手動で有効にする必要があります。有効にすると、毎日1回、保存期間を過ぎた未使用画像のファイルと記録を一緒に削除します。",
"retention_label": "保存期間(日)",
"retention_hint": "アップロード後、この期間を過ぎた未使用画像のみが整理対象になります。(1~3650日)",
"manual_hint": "今何が削除されるか事前に確認したい場合は、サーバーで次のコマンドを実行してください:php artisan sirsoft-ckeditor5:prune-unused-images --dry-run",
"open_uploads": "アップロード画像管理を開く"
}
},
"admin": {
"uploads": {
"title": "エディターアップロード画像",
"description": "エディターでアップロードされた画像を確認し、使用されなくなった画像を整理します。",
"refresh": "更新",
"open_settings": "整理設定を開く",
"reference_notice": "参照状態は投稿・ページ・商品説明などの登録されたコンテンツを確認して判定します。参照ソースを登録していない外部拡張のコンテンツのみで使用されている画像は「未参照」と表示される場合があるため、削除前に確認してください。",
"scan_limited_notice": "参照状態フィルターは最近{{count}}件を基準として判定します。それより古い画像はこのリストに表示されません。",
"empty": "アップロードされた画像がありません。",
"col": {
"thumbnail": "プレビュー",
"original_name": "元のファイル名",
"file_size": "サイズ",
"uploader": "アップローダー",
"created_at": "アップロード日時",
"referenced": "参照状態"
},
"badge": {
"referenced": "参照済み",
"unreferenced": "未参照"
},
"filter": {
"search_placeholder": "元のファイル名またはハッシュで検索",
"search_button": "検索",
"referenced_all": "すべての状態",
"referenced_only": "参照済みのみ",
"unreferenced_only": "未参照のみ",
"date_from": "開始日",
"date_to": "終了日",
"clear": "フィルターをリセット"
},
"delete": {
"button": "削除",
"confirm_title": "画像を削除しますか?",
"confirm_message": "選択した画像のファイルと記録が一緒に削除されます。この操作は取り消せません。",
"success": "画像を削除しました。"
},
"bulk_delete": {
"button": "選択削除",
"selected": "{{count}}件選択されました",
"confirm_title": "選択した画像を削除しますか?",
"confirm_message": "選択した{{count}}件のファイルと記録が一緒に削除されます。この操作は取り消せません。"
}
}
}
}
@@ -0,0 +1,18 @@
{
"sirsoft-ckeditor5": {
"name": "CKEditor 5 WYSIWYG エディター",
"description": "CKEditor5 プラグインが提供する権限"
},
"sirsoft-ckeditor5.uploads": {
"name": "エディター アップロード画像",
"description": "エディターにアップロードされた画像の閲覧・削除権限"
},
"sirsoft-ckeditor5.uploads.read": {
"name": "アップロード画像の閲覧",
"description": "エディター アップロード画像一覧・参照状態の閲覧"
},
"sirsoft-ckeditor5.uploads.delete": {
"name": "アップロード画像の削除",
"description": "エディター アップロード画像のファイル・記録の削除"
}
}
@@ -13,6 +13,7 @@
- 회원 정보 수정 화면의 탈퇴 확인 창 문구 일본어 번역 추가 (`admin.users.modals.withdraw_confirm_*`) — 되돌릴 수 없는 처리라는 안내와 함께 처리되는 항목 목록이 일본어 로케일에서 표시됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 드라이버 선택란에 저장된 값이 현재 사용할 수 없는 값일 때 표시되는 안내 문구 일본어 번역을 추가했습니다.
- 환경설정 > 업로드 탭의 「고아 첨부 정리」 카드 문구 일본어 번역을 추가했습니다 (`settings.upload.orphan_cleanup*`, `orphan_retention_days*`) — 자동 정리 토글과 보존기간 입력의 라벨·도움말이 일본어 로케일에서 표시됩니다.
### Changed
@@ -1392,7 +1392,13 @@
"image_max_width": "最大幅",
"image_max_height": "最大高さ",
"image_quality": "画像品質",
"image_quality_hint": "1〜100の値を入力してください。値が大きいほど品質が良くなります。"
"image_quality_hint": "1〜100の値を入力してください。値が大きいほど品質が良くなります。",
"orphan_cleanup": "孤立した添付ファイルのクリーンアップ",
"orphan_cleanup_desc": "フォームを保存せずに離脱して、所有者がいなくなった添付ファイルをクリーンアップします。",
"orphan_cleanup_enabled": "孤立した添付ファイルの自動クリーンアップ",
"orphan_cleanup_enabled_hint": "デフォルトはオフです — 管理者が直接有効化する必要があります。有効化すると、保持期間を過ぎた孤立した添付ファイルのファイルと記録を毎日削除します。サイトロゴなど現在使用中の添付ファイルは削除しません。",
"orphan_retention_days": "孤立した添付ファイルの保持期間(日数)",
"orphan_retention_days_hint": "アップロード後この期間が経過した孤立した添付ファイルのみがクリーンアップの対象になります。フォーム作成中の中断を考慮して、余裕を持って設定してください。(1~3650日)"
},
"seo": {
"meta_settings": "メタタグ設定",
+1
View File
@@ -21,6 +21,7 @@ return [
'backup_success' => 'Database backup started successfully.',
'backup_failed' => 'Failed to start database backup.',
'backup_error' => 'An error occurred while backing up database.',
'database_backup_unavailable' => 'Database backup is not available yet. Use the settings backup feature to back up your settings.',
'backup_path_required' => 'Please enter a backup path.',
'restore_success' => 'Settings restored successfully.',
'restore_failed' => 'Failed to restore settings.',
+6
View File
@@ -960,6 +960,10 @@ return [
'image_quality_integer' => 'Image quality must be an integer.',
'image_quality_min' => 'Image quality must be at least 1.',
'image_quality_max' => 'Image quality may not be greater than 100.',
'orphan_cleanup_enabled_boolean' => 'Orphan attachment cleanup must be enabled or disabled.',
'orphan_retention_days_integer' => 'Orphan attachment retention period must be an integer.',
'orphan_retention_days_min' => 'Orphan attachment retention period must be at least 1 day.',
'orphan_retention_days_max' => 'Orphan attachment retention period may not be greater than 3650 days.',
// SEO settings
'meta_title_suffix_max' => 'Meta title suffix may not be greater than 100 characters.',
@@ -1205,6 +1209,8 @@ return [
'image_max_width' => 'max image width',
'image_max_height' => 'max image height',
'image_quality' => 'image quality',
'orphan_cleanup_enabled' => 'orphan attachment cleanup',
'orphan_retention_days' => 'orphan attachment retention period',
// SEO settings
'meta_title_suffix' => 'meta title suffix',
'meta_description' => 'meta description',
+1
View File
@@ -21,6 +21,7 @@ return [
'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.',
'backup_failed' => '데이터베이스 백업에 실패했습니다.',
'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.',
'database_backup_unavailable' => '데이터베이스 백업 기능은 아직 제공하지 않습니다. 설정 백업은 설정 백업 기능을 이용해 주세요.',
'backup_path_required' => '백업 경로를 입력해주세요.',
'restore_success' => '설정이 성공적으로 복원되었습니다.',
'restore_failed' => '설정 복원에 실패했습니다.',
+6
View File
@@ -899,6 +899,10 @@ return [
'image_quality_integer' => '이미지 품질은 정수여야 합니다.',
'image_quality_min' => '이미지 품질은 1 이상이어야 합니다.',
'image_quality_max' => '이미지 품질은 100을 초과할 수 없습니다.',
'orphan_cleanup_enabled_boolean' => '고아 첨부 자동 정리는 사용/사용 안 함 중 하나여야 합니다.',
'orphan_retention_days_integer' => '고아 첨부 보존기간은 정수여야 합니다.',
'orphan_retention_days_min' => '고아 첨부 보존기간은 1일 이상이어야 합니다.',
'orphan_retention_days_max' => '고아 첨부 보존기간은 3650일을 초과할 수 없습니다.',
// SEO 설정
'meta_title_suffix_max' => '타이틀 접미사는 100자를 초과할 수 없습니다.',
@@ -1198,6 +1202,8 @@ return [
'image_max_width' => '이미지 최대 너비',
'image_max_height' => '이미지 최대 높이',
'image_quality' => '이미지 품질',
'orphan_cleanup_enabled' => '고아 첨부 자동 정리',
'orphan_retention_days' => '고아 첨부 보존기간',
// SEO 설정 필드
'meta_title_suffix' => '메타 타이틀 접미사',
'meta_description' => '메타 설명',
@@ -13,6 +13,11 @@
- 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919)
- 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다.
### Added
- 글쓰기 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다.
- 삭제한 첨부의 파일을 보존기간이 지난 뒤 완전히 파기하는 기능이 추가되었습니다. 게시판 환경설정 > 기본 설정에서 켤 수 있으며(기본 꺼짐), 보존기간은 휴지통 복원 가능 기간과 같게 유지하시면 됩니다.
### Changed
- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다.
@@ -155,6 +155,10 @@ return [
'max_comment_depth_min' => 0,
'max_comment_depth_max' => 10,
// 삭제 첨부 영구 정리 보존기간 (일)
'attachment_purge_retention_days_min' => 1,
'attachment_purge_retention_days_max' => 3650,
// 신고 정책
'auto_hide_threshold_min' => 0,
'auto_hide_threshold_max' => 100,
@@ -2,7 +2,7 @@
"_meta": {
"version": "1.0.0",
"description": "게시판 모듈 환경설정",
"categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications"]
"categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications", "attachment_settings"]
},
"defaults": {
"basic_defaults": {
@@ -100,6 +100,10 @@
{ "id": "mail", "is_active": true, "sort_order": 0 },
{ "id": "database", "is_active": true, "sort_order": 1 }
]
},
"attachment_settings": {
"purge_enabled": false,
"purge_retention_days": 30
}
},
"frontend_schema": {
@@ -118,6 +122,9 @@
},
"seo": {
"expose": false
},
"attachment_settings": {
"expose": false
}
}
}
@@ -393,6 +393,9 @@ HTTP/1.1 200
| seo.seo_boards | body | boolean | 아니오 | — | 게시판 목록 페이지의 SEO 페이지 생성 활성화 여부 |
| seo.seo_board | body | boolean | 아니오 | — | 개별 게시판 페이지의 SEO 페이지 생성 활성화 여부 |
| seo.seo_post_detail | body | boolean | 아니오 | — | 게시글 상세 페이지의 SEO 페이지 생성 활성화 여부 |
| attachment_settings | body | array | 아니오 | — | 첨부 정리 정책 카테고리 값. 삭제된 첨부의 물리 파일을 언제 파기할지 정합니다. |
| attachment_settings.purge_enabled | body | boolean | 아니오 | — | 삭제 첨부 영구 정리 활성화 여부. **기본 false** — 사용자 파일을 실제로 파기하므로 운영자가 직접 켜야 동작합니다. 예약 작업(`sirsoft-board:prune-attachments --scheduled`)이 이 값을 false 폴백으로 재확인합니다. |
| attachment_settings.purge_retention_days | body | integer | 아니오 | min 1, max 3650 | 삭제 첨부 보존기간(일, 기본 30). 게시글을 휴지통에서 복원하면 이 기간 안의 첨부는 함께 복원되므로, 휴지통 복원 가능 기간과 같게 유지해야 합니다. 하한·상한은 `config('sirsoft-board.limits')` 가 정하며 화면 입력도 같은 값을 읽습니다. |
**요청 예시**
+11
View File
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationChannelListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationDataListener;
use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener;
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
use Modules\Sirsoft\Board\Listeners\PostAttachmentCountSyncListener;
@@ -72,6 +73,15 @@ class Module extends AbstractModule
'schedule' => 'hourly',
'description' => '대시보드 게시물 현황 집계',
],
[
'command' => 'sirsoft-board:prune-attachments --scheduled',
'schedule' => 'daily',
'description' => '방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리',
// 임시 첨부 정리 파트는 상시 동작해야 하므로 스케줄 자체는 항상 등록한다.
// 사용자 파일을 실제로 파기하는 영구 정리 파트만 커맨드 내부에서 게이트한다
// (attachment_settings.purge_enabled, false 폴백).
'enabled_config' => null,
],
];
}
@@ -416,6 +426,7 @@ class Module extends AbstractModule
CommentReplySyncListener::class,
BoardPostsCountSyncListener::class,
BoardCommentsCountSyncListener::class,
Ckeditor5ReferenceSourcesListener::class,
];
}
@@ -0,0 +1,187 @@
/**
* 게시판 환경설정 — 첨부 정리 섹션 계약 테스트 (공개 #115)
*
* @description
* 삭제 첨부 영구 정리는 사용자 파일을 파기하므로 기본 꺼짐이다. 화면 바인딩 키가
* 저장 카테고리·기본값·서버 검증 세 지점과 일치하는지를 고정한다. 한 곳만 어긋나도
* 저장은 성공하는데 값이 조용히 버려진다.
*
* @effects board_settings_purge_toggle_default_off, board_settings_purge_keys_declared
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import generalTab from '../../../layouts/admin/partials/admin_board_settings/_tab_general.json';
/**
* 모듈 루트(module.json 기준)를 위로 훑어 찾는다.
*
* @returns 모듈 루트 절대경로
*/
function moduleRoot(): string {
let current = path.dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 10; depth++) {
if (fs.existsSync(path.join(current, 'module.json'))) {
return current;
}
current = path.dirname(current);
}
throw new Error('module.json 을 가진 모듈 루트를 찾지 못했습니다.');
}
/**
* 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다.
*
* @param node 탐색 시작 노드
* @param predicate 노드 판정 함수
* @returns 찾은 노드 또는 null
*/
function findNode(node: any, predicate: (n: any) => boolean): any {
if (!node || typeof node !== 'object') return null;
if (predicate(node)) return node;
for (const child of node.children ?? []) {
const found = findNode(child, predicate);
if (found) return found;
}
return null;
}
/**
* 이 화면을 렌더하는 admin 템플릿의 매니페스트가 선언한 컴포넌트 종류를 읽는다.
*
* 레이아웃 노드의 `type` 은 렌더러가 DOM 안전 필터링을 고르는 기준이라
* 매니페스트 선언과 어긋나면 `editorAttrs` 가 제거된다(DynamicRenderer).
*
* @param name 컴포넌트 이름
* @returns 매니페스트가 선언한 type
*/
function manifestType(name: string): string {
// 모듈 루트(modules/_bundled/sirsoft-board) 에서 저장소 루트로 두 단계 올라간다.
const repoRoot = path.dirname(path.dirname(path.dirname(moduleRoot())));
const manifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, 'templates/_bundled/sirsoft-admin_basic/components.json'),
'utf-8',
),
);
for (const [kind, entries] of Object.entries<any>(manifest.components ?? {})) {
if ((entries as any[]).some((entry) => entry.name === name)) {
return kind;
}
}
throw new Error(`매니페스트에 ${name} 선언이 없습니다.`);
}
const section = findNode(generalTab, (n) => n.id === 'attachment_cleanup_section');
describe('게시판 첨부 정리 설정 — 계약', () => {
it('기본 설정 탭에 첨부 정리 섹션이 있다', () => {
expect(section).not.toBeNull();
});
it('토글 노드의 type 이 매니페스트 선언과 같다', () => {
const toggle = findNode(section, (n) => n.name === 'Toggle');
// 어긋나면 렌더러가 editorAttrs 를 떼어내 이 노드만 레이아웃 편집기에서 표식을 잃는다.
expect(toggle.type).toBe(manifestType('Toggle'));
});
it('토글과 보존기간이 attachment_settings 카테고리 키를 바인딩한다', () => {
const toggle = findNode(section, (n) => n.name === 'Toggle');
const input = findNode(section, (n) => n.name === 'Input');
expect(toggle.props.name).toBe('attachment_settings.purge_enabled');
expect(input.props.name).toBe('attachment_settings.purge_retention_days');
expect(input.props.min).toContain('attachment_purge_retention_days_min');
expect(input.props.max).toContain('attachment_purge_retention_days_max');
});
it('변경 시 form 상태와 hasChanges 를 함께 갱신한다', () => {
const toggle = findNode(section, (n) => n.name === 'Toggle');
const action = toggle.actions.find((a: any) => a.type === 'change');
expect(action.params.hasChanges).toBe(true);
// 값 갱신은 정적 점 경로 키로 한다. 중첩 객체(`form: { attachment_settings: {...} }`)로
// 되돌리면 spread 누적이 되살아나 저장은 200 인데 값이 조용히 버려진다.
expect(action.params['form.attachment_settings.purge_enabled']).toBe('{{$event.target.checked}}');
expect(action.params.form).toBeUndefined();
// 키 자체에 표현식을 쓰면 경로가 해석되지 않는다 (setState 규약).
Object.keys(action.params).forEach((key) => {
expect(key).not.toContain('{{');
});
});
it('기본값이 꺼짐이고 보존기간 기본이 30일이다 (defaults.json)', () => {
const defaults = JSON.parse(
fs.readFileSync(path.join(moduleRoot(), 'config/settings/defaults.json'), 'utf-8'),
);
expect(defaults._meta.categories).toContain('attachment_settings');
expect(defaults.defaults.attachment_settings.purge_enabled).toBe(false);
expect(defaults.defaults.attachment_settings.purge_retention_days).toBe(30);
});
it('서버 검증이 같은 키를 받고 화면과 같은 한계값 출처를 쓴다', () => {
const request = fs.readFileSync(
path.join(moduleRoot(), 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'),
'utf-8',
);
const boardConfig = fs.readFileSync(path.join(moduleRoot(), 'config/board.php'), 'utf-8');
expect(request).toContain("'attachment_settings.purge_enabled' => ['nullable', 'boolean']");
// 화면 입력과 저장 규칙이 같은 limits 키를 읽어야 "화면이 허용한 값인데 422" 가 생기지 않는다.
expect(request).toContain("\$limits['attachment_purge_retention_days_min']");
expect(request).toContain("\$limits['attachment_purge_retention_days_max']");
expect(boardConfig).toContain("'attachment_purge_retention_days_min' => 1");
expect(boardConfig).toContain("'attachment_purge_retention_days_max' => 3650");
});
it('기본 설정 탭 저장 payload 에 attachment_settings 가 실린다', () => {
// 화면에 필드가 있어도 저장 요청 본문에 카테고리가 빠지면 저장은 200 인데 값만 사라진다
// (브라우저 실측 — 토글을 켜고 저장해도 파일에 반영되지 않음).
const settingsLayout = JSON.parse(
fs.readFileSync(
path.join(moduleRoot(), 'resources/layouts/admin/admin_board_settings.json'),
'utf-8',
),
);
const raw = JSON.stringify(settingsLayout);
const generalBranchStart = raw.indexOf("if (tab === 'general')");
const generalBranch = raw.slice(generalBranchStart, raw.indexOf('; }', generalBranchStart));
expect(generalBranchStart).toBeGreaterThan(-1);
expect(generalBranch).toContain('attachment_settings: form.attachment_settings || {}');
});
it('서버가 attachment_settings 카테고리를 저장 대상으로 받는다', () => {
const request = fs.readFileSync(
path.join(moduleRoot(), 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'),
'utf-8',
);
const validCategories = request.match(/\$validCategories = \[[^\]]*\]/)?.[0] ?? '';
expect(validCategories).toContain("'attachment_settings'");
});
it('스케줄이 등록되고 영구 정리 파트만 커맨드 내부에서 게이트된다', () => {
const manifest = fs.readFileSync(path.join(moduleRoot(), 'module.php'), 'utf-8');
const command = fs.readFileSync(
path.join(moduleRoot(), 'src/Console/Commands/PruneAttachmentsCommand.php'),
'utf-8',
);
expect(manifest).toContain('sirsoft-board:prune-attachments --scheduled');
expect(command).toContain("module_setting('sirsoft-board', 'attachment_settings.purge_enabled', false)");
});
});
@@ -1611,10 +1611,12 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => {
expect(option).not.toBeNull();
// click 액션으로 date_display_format: "standard" 설정
// 키는 점 경로다 — 중첩 객체로 넘기면 spread 누적이 생겨 저장 payload 가 어긋난다.
const clickAction = option.actions?.find((a: any) => a.type === 'click');
expect(clickAction).toBeDefined();
expect(clickAction.handler).toBe('setState');
expect(clickAction.params?.form?.display?.date_display_format).toBe('standard');
expect(clickAction.params?.['form.display.date_display_format']).toBe('standard');
expect(clickAction.params?.form).toBeUndefined();
});
it('relative 옵션 라디오 버튼이 존재한다', () => {
@@ -1622,10 +1624,12 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => {
expect(option).not.toBeNull();
// click 액션으로 date_display_format: "relative" 설정
// 키는 점 경로다 — 중첩 객체로 넘기면 spread 누적이 생겨 저장 payload 가 어긋난다.
const clickAction = option.actions?.find((a: any) => a.type === 'click');
expect(clickAction).toBeDefined();
expect(clickAction.handler).toBe('setState');
expect(clickAction.params?.form?.display?.date_display_format).toBe('relative');
expect(clickAction.params?.['form.display.date_display_format']).toBe('relative');
expect(clickAction.params?.form).toBeUndefined();
});
it('라디오 버튼 name이 display.date_display_format이다', () => {
@@ -60,9 +60,15 @@
"report_notification": "Report Notification",
"report_notification_description": "Configure which administrators receive notifications and how notifications are sent when a report is submitted.",
"report_permissions": "Report Management Permissions",
"report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role."
"report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role.",
"attachment_cleanup": "Attachment Cleanup",
"attachment_cleanup_description": "Decide when the files of deleted attachments are destroyed. Temporary attachments uploaded while writing but never saved are cleaned up daily regardless of this setting."
},
"fields": {
"attachment_purge_enabled": "Purge deleted attachments",
"attachment_purge_enabled_hint": "Off by default — the operator must turn it on for the daily automatic cleanup to run. When on, files and records of attachments deleted longer ago than the retention period are permanently removed. Cleanup commands run directly on the server are not affected by this setting.",
"attachment_purge_retention_days": "Deleted attachment retention (days)",
"attachment_purge_retention_days_hint": "Restoring a post from the trash also restores attachments within this period. Keep it aligned with your trash retention window. (1 ~ 3650 days)",
"type": "Board Type",
"secret_mode": "Secret Mode",
"per_page": "Posts per Page (PC)",
@@ -60,9 +60,15 @@
"report_notification": "신고 알림",
"report_notification_description": "신고 접수 시 알림을 받을 관리자와 알림 발송 방식을 설정합니다.",
"report_permissions": "신고 관리 권한",
"report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다."
"report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다.",
"attachment_cleanup": "첨부파일 정리",
"attachment_cleanup_description": "삭제한 첨부파일의 물리 파일을 언제 파기할지 정합니다. 글쓰기 중 올렸다가 저장하지 않은 임시 첨부는 이 설정과 무관하게 매일 자동 정리됩니다."
},
"fields": {
"attachment_purge_enabled": "삭제 첨부 영구 정리",
"attachment_purge_enabled_hint": "기본 꺼짐 — 운영자가 직접 켜야 매일 자동 정리가 동작합니다. 켜면 삭제된 지 보존기간이 지난 첨부의 파일과 기록을 영구 삭제합니다. 서버에서 직접 실행하는 정리 명령은 이 설정과 무관하게 동작합니다.",
"attachment_purge_retention_days": "삭제 첨부 보존기간 (일)",
"attachment_purge_retention_days_hint": "휴지통에서 게시글을 복원하면 이 기간 안의 첨부는 함께 복원됩니다. 휴지통 복원 가능 기간과 같게 유지하십시오. (1 ~ 3650 일)",
"type": "게시판 유형",
"secret_mode": "비밀글 모드",
"per_page": "페이지당 게시글 수 (PC)",
@@ -641,7 +641,7 @@
"target": "/api/modules/sirsoft-board/admin/settings",
"params": {
"method": "PUT",
"body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}"
"body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {}, attachment_settings: form.attachment_settings || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}"
},
"onSuccess": [
{
@@ -81,14 +81,8 @@
"handler": "setState",
"params": {
"target": "local",
"form": {
"...": "{{_local.form}}",
"display": {
"...": "{{_local.form?.display}}",
"date_display_format": "standard"
}
},
"hasChanges": true
"hasChanges": true,
"form.display.date_display_format": "standard"
}
}
],
@@ -110,14 +104,8 @@
"handler": "setState",
"params": {
"target": "local",
"form": {
"...": "{{_local.form}}",
"display": {
"...": "{{_local.form?.display}}",
"date_display_format": "standard"
}
},
"hasChanges": true
"hasChanges": true,
"form.display.date_display_format": "standard"
}
}
]
@@ -170,14 +158,8 @@
"handler": "setState",
"params": {
"target": "local",
"form": {
"...": "{{_local.form}}",
"display": {
"...": "{{_local.form?.display}}",
"date_display_format": "relative"
}
},
"hasChanges": true
"hasChanges": true,
"form.display.date_display_format": "relative"
}
}
],
@@ -199,14 +181,8 @@
"handler": "setState",
"params": {
"target": "local",
"form": {
"...": "{{_local.form}}",
"display": {
"...": "{{_local.form?.display}}",
"date_display_format": "relative"
}
},
"hasChanges": true
"hasChanges": true,
"form.display.date_display_format": "relative"
}
}
]
@@ -251,6 +227,128 @@
]
}
]
},
{
"id": "attachment_cleanup_section",
"type": "basic",
"name": "Div",
"props": {
"className": "admin-card mt-6"
},
"children": [
{
"type": "basic",
"name": "H2",
"props": {
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.settings.sections.attachment_cleanup"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "card-description"
},
"text": "$t:sirsoft-board.admin.settings.sections.attachment_cleanup_description"
},
{
"id": "attachment_purge_enabled_field",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_enabled"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-tertiary"
},
"text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_enabled_hint"
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "attachment_settings.purge_enabled",
"disabled": "{{_computed.isReadOnly}}",
"checked": "{{!!_local.form?.attachment_settings?.purge_enabled}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"hasChanges": true,
"form.attachment_settings.purge_enabled": "{{$event.target.checked}}"
}
}
]
}
]
},
{
"id": "attachment_purge_retention_days_field",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_retention_days"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-tertiary"
},
"text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_retention_days_hint"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "attachment_settings.purge_retention_days",
"min": "{{_local?.form?._meta?.limits?.attachment_purge_retention_days_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.attachment_purge_retention_days_max ?? 3650}}",
"className": "input w-40",
"disabled": "{{_computed.isReadOnly}}",
"value": "{{_local.form?.attachment_settings?.purge_retention_days ?? 30}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"hasChanges": true,
"form.attachment_settings.purge_retention_days": "{{$event.target.value}}"
}
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,180 @@
<?php
namespace Modules\Sirsoft\Board\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Services\AttachmentService;
/**
* 게시판 첨부파일 정리 커맨드
*
* 두 파트를 순차 실행합니다.
*
* 1. 임시 첨부 정리 (항상 실행) — 글쓰기 폼에서 올린 뒤 저장 없이 이탈해 남은 첨부.
* `temp_key` 가 남아 있으면 끝내 연결되지 않은 폼 세션 부산물이라 오탐 여지가 없다.
* 2. 소프트 삭제 첨부 영구 정리 (운영자 옵트인) — 삭제된 첨부는 휴지통 복원 대비로
* 파일이 남는다. 사용자 파일을 실제로 파기하므로 기본 꺼짐이며, 스케줄 호출
* (`--scheduled`)에서 설정을 false 폴백으로 확인한 뒤에만 수행한다.
*
* 토글이 막는 것은 **자동(스케줄) 실행**이다. `--scheduled` 없이 서버에서 직접 실행하면
* 토글과 무관하게 파기한다 — 운영자가 의도해서 친 명령이기 때문이다.
*
* @example php artisan sirsoft-board:prune-attachments --dry-run
* @example php artisan sirsoft-board:prune-attachments --temp-days=1
*/
class PruneAttachmentsCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'sirsoft-board:prune-attachments
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--temp-days=2 : 임시 첨부 보존기간(일)}
{--purge-days= : 소프트 삭제 첨부 보존기간(일) 재정의 — 미지정 시 모듈 설정값}
{--scheduled : 스케줄러 호출 표시 — 영구 정리 토글이 꺼져 있으면 그 파트만 건너뜀 (이 옵션 없이 직접 실행하면 토글과 무관하게 파기)}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '방치된 임시 첨부와 보존기간이 지난 삭제 첨부를 정리합니다.';
/**
* @param AttachmentService $attachmentService 첨부 서비스
*/
public function __construct(
protected AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
$limit = max(1, (int) $this->option('limit'));
$isDryRun = (bool) $this->option('dry-run');
$this->pruneTemp($limit, $isDryRun);
$this->purgeSoftDeleted($limit, $isDryRun);
return Command::SUCCESS;
}
/**
* 임시 첨부 정리 파트 (항상 실행).
*
* @param int $limit 최대 처리 건수
* @param bool $isDryRun 판정만 수행할지 여부
*/
private function pruneTemp(int $limit, bool $isDryRun): void
{
$days = (int) $this->option('temp-days');
// 보존기간 0 이하 = 방금 올린 임시 첨부까지 지우게 되므로 차단한다.
if ($days < 1) {
$this->info('임시 첨부 보존기간이 1일 미만이어서 임시 첨부 정리를 수행하지 않았습니다.');
return;
}
$result = $this->attachmentService->pruneTempUploads($days, $limit, $isDryRun);
if ($isDryRun) {
$this->info("[DRY RUN] 보존기간({$days}일) 경과 미연결 임시 첨부: {$result['scanned']}건");
return;
}
$this->info(sprintf(
'보존기간(%d일) 경과 임시 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
$days,
$result['scanned'],
$result['deleted'],
$result['failed'],
));
Log::info('PruneAttachmentsCommand: 임시 첨부 정리 완료', [
'days' => $days,
'limit' => $limit,
] + $result);
}
/**
* 소프트 삭제 첨부 영구 정리 파트 (운영자 옵트인).
*
* @param int $limit 최대 처리 건수
* @param bool $isDryRun 판정만 수행할지 여부
*/
private function purgeSoftDeleted(int $limit, bool $isDryRun): void
{
if ($this->option('scheduled') && ! $this->isPurgeEnabled()) {
$this->info('삭제 첨부 영구 정리가 꺼져 있어 실행하지 않았습니다. (attachment_settings.purge_enabled = false)');
return;
}
$days = $this->resolvePurgeDays();
if ($days < 1) {
$this->info('삭제 첨부 보존기간이 1일 미만이어서 영구 정리를 수행하지 않았습니다.');
return;
}
$result = $this->attachmentService->purgeSoftDeleted($days, $limit, $isDryRun);
if ($isDryRun) {
$this->info("[DRY RUN] 보존기간({$days}일) 경과 삭제 첨부: {$result['scanned']}건");
return;
}
$this->info(sprintf(
'보존기간(%d일) 경과 삭제 첨부 %d건 중 %d건을 영구 삭제했습니다. (실패 %d건)',
$days,
$result['scanned'],
$result['deleted'],
$result['failed'],
));
Log::info('PruneAttachmentsCommand: 삭제 첨부 영구 정리 완료', [
'days' => $days,
'limit' => $limit,
] + $result);
}
/**
* 영구 정리 토글을 false 폴백으로 조회합니다.
*
* @return bool 영구 정리 활성 여부
*/
private function isPurgeEnabled(): bool
{
return (bool) module_setting('sirsoft-board', 'attachment_settings.purge_enabled', false);
}
/**
* 영구 정리 보존기간을 해석합니다 (옵션 > 모듈 설정 > 기본 30일).
*
* @return int 보존기간(일)
*/
private function resolvePurgeDays(): int
{
$option = $this->option('purge-days');
if ($option !== null && $option !== '') {
return (int) $option;
}
return (int) module_setting('sirsoft-board', 'attachment_settings.purge_retention_days', 30);
}
}
@@ -58,6 +58,7 @@ class StoreBoardSettingsRequest extends FormRequest
'spam_security.comment_cooldown_seconds',
'spam_security.report_cooldown_seconds',
'spam_security.view_count_cache_ttl',
'attachment_settings.purge_retention_days',
];
$booleanFields = [
@@ -73,6 +74,7 @@ class StoreBoardSettingsRequest extends FormRequest
'seo.seo_boards',
'seo.seo_board',
'seo.seo_post_detail',
'attachment_settings.purge_enabled',
];
$data = $this->all();
@@ -262,6 +264,11 @@ class StoreBoardSettingsRequest extends FormRequest
'seo.seo_boards' => ['nullable', 'boolean'],
'seo.seo_board' => ['nullable', 'boolean'],
'seo.seo_post_detail' => ['nullable', 'boolean'],
// 첨부 정리 정책 — 사용자 파일을 파기하므로 보존기간 하한을 서버가 강제한다.
// 상한(3650일)은 "사실상 정리하지 않음" 과 구분되지 않는 값을 저장하지 않기 위함이다.
'attachment_settings.purge_enabled' => ['nullable', 'boolean'],
'attachment_settings.purge_retention_days' => ['nullable', 'integer', 'min:'.$limits['attachment_purge_retention_days_min'], 'max:'.$limits['attachment_purge_retention_days_max']],
];
}
@@ -311,7 +318,7 @@ class StoreBoardSettingsRequest extends FormRequest
public function validatedSettings(): array
{
$validated = $this->validated();
$validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications'];
$validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications', 'attachment_settings'];
return array_filter(
$validated,
@@ -67,6 +67,9 @@ trait ReadsBoardLimits
'new_display_hours_min' => 0,
'new_display_hours_max' => 720,
'attachment_purge_retention_days_min' => 1,
'attachment_purge_retention_days_max' => 3650,
];
/**
@@ -0,0 +1,57 @@
<?php
namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 게시판 본문을 CKEditor5 이미지 참조 스캔 대상으로 등록하는 리스너
*
* `sirsoft-ckeditor5.image.filter_reference_sources` 필터 훅에 게시글 본문 컬럼을
* 덧붙입니다. 등록하지 않으면 게시글에만 쓰이는 이미지가 "미참조" 로 판정돼
* 자동 정리 대상이 됩니다.
*
* 테이블명 문자열만 덧붙이며 DB 에 접근하지 않습니다 — 실제 조회와 존재 검증은
* 플러그인의 참조 판정 서비스가 수행합니다.
*/
class Ckeditor5ReferenceSourcesListener implements HookListenerInterface
{
/**
* 구독할 훅 목록 반환
*
* @return array<string, mixed>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ckeditor5.image.filter_reference_sources' => [
'method' => 'addBoardSources',
'priority' => 10,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트를 처리합니다.
*
* Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로
* 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다.
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void {}
/**
* 게시판 참조 소스를 추가합니다.
*
* @param array $sources 기존 참조 소스 목록
* @return array 게시판 소스가 추가된 목록
*/
public function addBoardSources(array $sources): array
{
$sources[] = ['table' => 'board_posts', 'columns' => ['content']];
return $sources;
}
}
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Providers;
use App\Extension\BaseModuleServiceProvider;
use App\Seo\SitemapGenerator;
use Modules\Sirsoft\Board\Console\Commands\AggregateBoardStatsCommand;
use Modules\Sirsoft\Board\Console\Commands\PruneAttachmentsCommand;
use Modules\Sirsoft\Board\Repositories\AttachmentRepository;
use Modules\Sirsoft\Board\Repositories\BoardRepository;
use Modules\Sirsoft\Board\Repositories\BoardStatRepository;
@@ -88,6 +89,7 @@ class BoardServiceProvider extends BaseModuleServiceProvider
*/
protected array $commands = [
AggregateBoardStatsCommand::class,
PruneAttachmentsCommand::class,
];
/**
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Board\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
@@ -431,4 +432,51 @@ class AttachmentRepository implements AttachmentRepositoryInterface
{
return Attachment::where('board_id', $boardId)->forceDelete();
}
/**
* 게시글에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다.
*
* @param Carbon $threshold 기준 시각
* @param int $limit 최대 조회 건수
* @return Collection 임시 첨부 목록
*/
public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection
{
return Attachment::query()
->whereNotNull('temp_key')
->whereNull('post_id')
->where('created_at', '<', $threshold)
->orderBy('created_at')
->orderBy('id')
->limit($limit)
->get(['id', 'board_id', 'temp_key', 'disk', 'path', 'created_at']);
}
/**
* 소프트 삭제된 지 오래된 첨부를 오래된 순으로 조회합니다.
*
* @param Carbon $threshold 기준 시각
* @param int $limit 최대 조회 건수
* @return Collection 소프트 삭제 첨부 목록
*/
public function findSoftDeletedOlderThan(Carbon $threshold, int $limit): Collection
{
return Attachment::onlyTrashed()
->where('deleted_at', '<', $threshold)
->orderBy('deleted_at')
->orderBy('id')
->limit($limit)
->get(['id', 'board_id', 'post_id', 'disk', 'path', 'deleted_at']);
}
/**
* 첨부 레코드를 영구 삭제합니다.
*
* @param Attachment $attachment 첨부파일 모델
* @return bool 삭제 성공 여부
*/
public function forceDelete(Attachment $attachment): bool
{
return (bool) $attachment->forceDelete();
}
}
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Board\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Post;
@@ -198,4 +199,36 @@ interface AttachmentRepositoryInterface
* @return int 삭제된 첨부파일 수
*/
public function forceDeleteByBoardId(int $boardId): int;
/**
* 게시글에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다.
*
* 글쓰기 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채
* `board_id = 0` 인 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @return Collection 임시 첨부 목록 (created_at 오름차순)
*/
public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection;
/**
* 소프트 삭제된 지 오래된 첨부를 오래된 순으로 조회합니다.
*
* 첨부 삭제는 소프트 삭제만 수행하므로(휴지통 복원 대비) 물리 파일은 남아 있습니다.
* 보존기간이 지난 뒤 파일과 기록을 함께 파기할 대상을 찾습니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 삭제분이 대상)
* @param int $limit 최대 조회 건수
* @return Collection 소프트 삭제 첨부 목록 (deleted_at 오름차순)
*/
public function findSoftDeletedOlderThan(Carbon $threshold, int $limit): Collection;
/**
* 첨부 레코드를 영구 삭제합니다.
*
* @param Attachment $attachment 첨부파일 모델
* @return bool 삭제 성공 여부
*/
public function forceDelete(Attachment $attachment): bool;
}
@@ -9,6 +9,7 @@ use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -440,8 +441,9 @@ class AttachmentService
// Before 훅
HookManager::doAction('sirsoft-board.attachment.before_delete', $attachment);
// 물리 파일은 삭제하지 않음 — 소프트 딜리트만 수행
// 추후 배치 작업(Artisan Command + Scheduler)으로 보존 기간 경과 후 정리 예정
// 물리 파일은 삭제하지 않음 — 소프트 딜리트만 수행 (휴지통 복원 대비).
// 보존기간이 지난 뒤의 파일·기록 파기는 `sirsoft-board:prune-attachments` 가
// 담당한다 (purgeSoftDeleted). 운영자가 설정에서 켰을 때만 동작한다.
// DB에서 소프트 삭제
$result = $this->repository->delete($slug, $id);
@@ -463,6 +465,151 @@ class AttachmentService
return $result;
}
/**
* 게시글에 연결되지 않은 채 방치된 임시 첨부를 정리합니다.
*
* 글쓰기 폼에서 파일만 올리고 저장하지 않고 이탈하면 `temp_key` 만 남은 행과 그 파일이
* 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 "끝내 연결되지
* 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다.
*
* 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다.
*
* @param int $days 보존기간(일)
* @param int $limit 한 회차에 처리할 최대 건수
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array
{
$threshold = Carbon::now()->subDays($days);
$attachments = $this->repository->findStaleTempAttachments($threshold, $limit);
$result = $this->purgeCollection($attachments, $dryRun, '임시 게시판 첨부');
if (! $dryRun) {
$this->removeEmptyTempDirectories($attachments);
}
return $result;
}
/**
* 파일을 모두 지운 temp_key 디렉토리를 정리합니다.
*
* 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
* 저장소에는 흔적이 계속 늘어납니다.
*
* 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 첨부가 limit 에 걸려 이번 회차에서
* 빠진 경우 등) 삭제하지 않습니다.
*
* @param Collection $attachments 이번 회차에 처리한 첨부 목록
*/
private function removeEmptyTempDirectories(Collection $attachments): void
{
$directories = [];
foreach ($attachments as $attachment) {
$directory = dirname((string) $attachment->path);
if ($directory === '' || $directory === '.') {
continue;
}
$directories[$attachment->disk.'|'.$directory] = [$attachment->disk, $directory];
}
foreach ($directories as [$disk, $directory]) {
$storage = $this->storageForRow($disk);
if ($storage->files('attachments', $directory) !== []) {
continue;
}
$storage->deleteDirectory('attachments', $directory);
}
}
/**
* 소프트 삭제된 지 보존기간이 지난 첨부를 파일까지 영구 파기합니다.
*
* 첨부 삭제는 소프트 삭제만 수행하므로 파일이 남아 있습니다. 게시글 복원
* (restoreCascadedByPostId)은 첨부의 `deleted_at` 이 보존기간 안에 있을 때에만 첨부까지
* 온전히 복원되므로, 보존기간은 휴지통 복원 가능 기간과 같게 유지해야 합니다.
*
* @param int $days 보존기간(일)
* @param int $limit 한 회차에 처리할 최대 건수
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
public function purgeSoftDeleted(int $days, int $limit, bool $dryRun = false): array
{
$threshold = Carbon::now()->subDays($days);
$attachments = $this->repository->findSoftDeletedOlderThan($threshold, $limit);
return $this->purgeCollection($attachments, $dryRun, '소프트 삭제 게시판 첨부');
}
/**
* 첨부 컬렉션을 파일 → 기록 순으로 영구 파기합니다.
*
* @param Collection $attachments 대상 첨부 목록
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @param string $label 로그용 대상 설명
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
private function purgeCollection(Collection $attachments, bool $dryRun, string $label): array
{
$result = ['scanned' => $attachments->count(), 'deleted' => 0, 'failed' => 0];
if ($dryRun) {
return $result;
}
foreach ($attachments as $attachment) {
$storage = $this->storageForRow($attachment->disk);
if ($storage->exists('attachments', $attachment->path) && ! $storage->delete('attachments', $attachment->path)) {
Log::warning("{$label} 파일 삭제 실패 — 기록 보존", [
'attachment_id' => $attachment->id,
'disk' => $attachment->disk,
'path' => $attachment->path,
]);
$result['failed']++;
continue;
}
$this->repository->forceDelete($attachment);
$result['deleted']++;
}
return $result;
}
/**
* 첨부 행에 기록된 disk 기준 스토리지를 반환합니다.
*
* 디스크를 전환한 뒤에도 전환 이전 행의 파일을 그 행의 실제 저장 위치에서 지우기 위한
* 해석입니다. 미등록 disk(그 디스크를 제공하던 확장이 비활성화된 경우)는 주입 스토리지로
* 폴백합니다 — withDisk 로 미등록 disk 인스턴스를 만들면 이후 호출이 예외가 됩니다.
*
* @param string|null $disk 행의 disk 컬럼 값
* @return StorageInterface 행 disk 의 스토리지
*/
private function storageForRow(?string $disk): StorageInterface
{
if ($disk === null || $disk === '' || $disk === $this->storage->getDisk()) {
return $this->storage;
}
if (config("filesystems.disks.{$disk}") === null) {
return $this->storage;
}
return $this->storage->withDisk($disk);
}
/**
* 순서 변경
*
@@ -401,6 +401,10 @@ return [
'override_values.max_file_size' => 'Max File Size',
'override_values.max_file_count' => 'Max File Count',
'override_values.new_display_hours' => 'New Display Hours',
// attachment_settings
'attachment_settings.purge_enabled' => 'Permanent Cleanup Of Deleted Attachments',
'attachment_settings.purge_retention_days' => 'Deleted Attachment Retention Period',
],
],
@@ -401,6 +401,10 @@ return [
'override_values.max_file_size' => '최대 파일 크기',
'override_values.max_file_count' => '최대 파일 개수',
'override_values.new_display_hours' => '신규 표시 시간',
// attachment_settings
'attachment_settings.purge_enabled' => '삭제 첨부 영구 정리',
'attachment_settings.purge_retention_days' => '삭제 첨부 보존기간',
],
],
@@ -144,13 +144,14 @@ class BoardSettingsControllerTest extends ModuleTestCase
$response->assertStatus(200);
// 저장된 값이 파일에 반영되었는지 확인
$filePath = $this->settingsStoragePath.'/basic_defaults.json';
$this->assertFileExists($filePath);
// 저장 결과는 조회 API 로 확인한다 — settings 디스크는 테스트 하네스가 페이크로
// 대체하므로 실제 storage 경로를 직접 읽으면 저장 성공에도 파일이 없다.
$saved = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-board/admin/settings/basic_defaults');
$content = json_decode(File::get($filePath), true);
$this->assertEquals(30, $content['per_page']);
$this->assertEquals(10, $content['per_page_mobile']);
$saved->assertStatus(200);
$this->assertEquals(30, $saved->json('data.settings.per_page'));
$this->assertEquals(10, $saved->json('data.settings.per_page_mobile'));
}
/**
@@ -170,13 +171,13 @@ class BoardSettingsControllerTest extends ModuleTestCase
$response->assertStatus(200);
// 저장된 값이 파일에 반영되었는지 확인
$filePath = $this->settingsStoragePath.'/notifications.json';
$this->assertFileExists($filePath);
// 저장 결과는 조회 API 로 확인한다 (settings 디스크 페이크 — 위 주석 참조).
$saved = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-board/admin/settings/notifications');
$content = json_decode(File::get($filePath), true);
$this->assertIsArray($content['channels']);
$this->assertEquals('mail', $content['channels'][0]['id']);
$saved->assertStatus(200);
$this->assertIsArray($saved->json('data.settings.channels'));
$this->assertEquals('mail', $saved->json('data.settings.channels.0.id'));
}
/**
@@ -617,8 +618,11 @@ class BoardSettingsControllerTest extends ModuleTestCase
$response->assertStatus(200);
$content = json_decode(File::get($this->settingsStoragePath.'/report_policy.json'), true);
$this->assertSame(0, (int) $content['auto_hide_threshold']);
$saved = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-board/admin/settings/report_policy');
$saved->assertStatus(200);
$this->assertSame(0, (int) $saved->json('data.settings.auto_hide_threshold'));
}
/**
@@ -759,4 +763,49 @@ class BoardSettingsControllerTest extends ModuleTestCase
$board->refresh();
$this->assertEquals(10, $board->per_page);
}
/**
* 첨부 정리 설정이 general 탭 저장으로 실제 반영되는지 확인 (회귀)
*
* 화면은 general 탭에서 이 카테고리를 함께 제출한다. 서버가 카테고리를 허용하지 않으면
* 저장은 200 인데 값만 사라져, 운영자가 토글을 켜도 정리가 영원히 꺼진 상태로 남는다.
*/
public function test_attachment_cleanup_settings_are_saved_on_general_tab(): void
{
$response = $this->actingAs($this->adminUser)
->putJson('/api/modules/sirsoft-board/admin/settings', [
'_tab' => 'general',
'display' => ['date_display_format' => 'standard'],
'attachment_settings' => [
'purge_enabled' => true,
'purge_retention_days' => 15,
],
]);
$response->assertStatus(200);
$followUp = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-board/admin/settings/attachment_settings');
$followUp->assertStatus(200);
$this->assertTrue($followUp->json('data.settings.purge_enabled'));
$this->assertSame(15, $followUp->json('data.settings.purge_retention_days'));
}
/**
* 보존기간 하한(1일) 미만은 거부되는지 확인 — 사용자 파일을 파기하는 설정이므로.
*/
public function test_attachment_purge_retention_days_below_minimum_is_rejected(): void
{
$response = $this->actingAs($this->adminUser)
->putJson('/api/modules/sirsoft-board/admin/settings', [
'_tab' => 'general',
'attachment_settings' => [
'purge_enabled' => true,
'purge_retention_days' => 0,
],
]);
$response->assertStatus(422);
}
}
@@ -0,0 +1,339 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Console;
use App\Extension\Storage\ModuleStorageDriver;
use App\Services\ModuleSettingsService;
use Mockery;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 게시판 첨부 정리 커맨드 테스트
*
* 임시 첨부 정리는 상시, 소프트 삭제 첨부 영구 정리는 운영자 옵트인입니다.
* 두 파트의 게이트가 서로 섞이지 않는지를 회귀로 고정합니다.
*/
class PruneAttachmentsCommandTest extends ModuleTestCase
{
/**
* 모듈 설정 조회를 지정 값으로 고정합니다.
*
* @param array<string, mixed> $values 설정 키 => 값
*/
private function fakeModuleSettings(array $values): void
{
$service = Mockery::mock(ModuleSettingsService::class);
$service->shouldReceive('get')
->andReturnUsing(function (string $identifier, ?string $key = null, mixed $default = null) use ($values) {
return array_key_exists($key, $values) ? $values[$key] : $default;
});
$this->app->instance(ModuleSettingsService::class, $service);
}
/**
* 첨부 파일과 기록을 함께 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @return Attachment 생성된 첨부
*/
private function makeAttachment(array $attributes = []): Attachment
{
$filename = uniqid('board-att-').'.png';
$path = $attributes['path'] ?? "free/temp/tempkey/{$filename}";
(new ModuleStorageDriver('sirsoft-board', 'modules'))->put('attachments', $path, 'bytes');
return Attachment::create(array_merge([
'board_id' => 0,
'post_id' => null,
'temp_key' => 'tempkey',
'original_filename' => $filename,
'stored_filename' => $filename,
'disk' => 'modules',
'path' => $path,
'mime_type' => 'image/png',
'size' => 5,
'collection' => 'attachments',
'order' => 1,
], $attributes));
}
/**
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_deletes_file_and_record
*/
#[Test]
public function stale_temp_attachments_lose_both_file_and_record(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path)
);
$this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=within_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_keeps_within_retention
*/
#[Test]
public function temp_attachment_within_retention_is_kept(): void
{
$attachment = $this->makeAttachment();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* 게시글에 연결된(temp_key 가 비워진) 첨부는 임시 정리 대상이 아니다.
*
* @scenario age=past_retention, attachment_state=linked, purge_toggle=off
*
* @effects board_temp_prune_skips_linked
*/
#[Test]
public function linked_attachment_is_never_treated_as_temporary(): void
{
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('linked-').'.png',
]);
$attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=off
*
* @effects board_purge_gate_off_by_default
*/
#[Test]
public function scheduled_run_does_not_purge_soft_deleted_when_the_toggle_is_off(): void
{
$this->fakeModuleSettings(['attachment_settings.purge_enabled' => false]);
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('soft-').'.png',
]);
$attachment->delete();
$attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
$this->assertNotNull(Attachment::withTrashed()->find($attachment->id));
}
/**
* @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=on
*
* @effects board_purge_gate_on
*/
#[Test]
public function scheduled_run_purges_soft_deleted_when_the_toggle_is_on(): void
{
$this->fakeModuleSettings([
'attachment_settings.purge_enabled' => true,
'attachment_settings.purge_retention_days' => 30,
]);
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('purge-').'.png',
]);
$path = $attachment->path;
$attachment->delete();
$attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
$this->assertNull(Attachment::withTrashed()->find($attachment->id));
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $path)
);
}
/**
* 보존기간 안의 소프트 삭제 첨부는 복원 가능해야 하므로 파기하지 않는다.
*
* @scenario age=within_retention, attachment_state=soft_deleted, purge_toggle=on
*
* @effects board_purge_keeps_restorable_window
*/
#[Test]
public function soft_deleted_attachment_within_retention_stays_restorable(): void
{
$this->fakeModuleSettings([
'attachment_settings.purge_enabled' => true,
'attachment_settings.purge_retention_days' => 30,
]);
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('recent-').'.png',
]);
$attachment->delete();
$this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
$trashed = Attachment::withTrashed()->find($attachment->id);
$this->assertNotNull($trashed);
$this->assertTrue(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path),
'복원 가능 기간 안에는 파일도 남아 있어야 한다.'
);
}
/**
* @effects board_prune_dry_run_no_delete
*/
#[Test]
public function dry_run_reports_targets_without_deleting(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --dry-run')
->expectsOutputToContain('[DRY RUN]')
->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* @effects board_temp_prune_days_guard
*/
#[Test]
public function temp_retention_below_one_day_performs_no_cleanup(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --temp-days=0 --purge-days=0')
->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀)
*
* 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
* 저장소 흔적은 계속 늘어난다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_removes_empty_directory
*/
#[Test]
public function emptied_temp_directory_is_removed(): void
{
$attachment = $this->makeAttachment(['path' => 'free/temp/tempkey-empty-dir/'.uniqid('t-').'.png']);
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$storage = new ModuleStorageDriver('sirsoft-board', 'modules');
$this->assertSame(
[],
$storage->files('attachments', 'free/temp/tempkey-empty-dir'),
'디렉토리 안의 파일은 모두 삭제되어야 한다.'
);
$this->assertDirectoryDoesNotExist(
rtrim($storage->getBasePath('attachments'), '/\\').'/free/temp/tempkey-empty-dir',
'비워진 temp 디렉토리는 남기지 않는다.'
);
}
/**
* 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_keeps_directory_with_remaining_files
*/
#[Test]
public function temp_directory_with_remaining_files_is_kept(): void
{
$directory = 'free/temp/tempkey-partial';
$processed = $this->makeAttachment(['path' => $directory.'/'.uniqid('done-').'.png']);
$processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
// 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태
$storage = new ModuleStorageDriver('sirsoft-board', 'modules');
$storage->put('attachments', $directory.'/keep.png', 'bytes');
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertTrue($storage->exists('attachments', $directory.'/keep.png'));
}
/**
* 대상이 한 회차 상한을 넘어도 누락 없이 전수 처리된다 (경계).
*
* 순회는 `--limit` 으로 잘린 단일 윈도우를 훑는다. 처리하며 행을 지우므로, 남은 대상이
* 다음 회차에서 반드시 잡혀야 적체가 수렴한다 — 커서가 밀려 영영 건너뛰는 행이 있으면
* 정리를 매일 돌려도 잔존물이 남는다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_processes_all_across_runs
*/
#[Test]
public function targets_beyond_the_limit_are_processed_on_the_next_run(): void
{
$attachments = [];
for ($i = 0; $i < 3; $i++) {
$attachment = $this->makeAttachment([
'path' => "free/temp/tempkey-batch-{$i}/".uniqid('b-').'.png',
]);
$attachment->forceFill(['created_at' => now()->subDays(5 + $i)])->saveQuietly();
$attachments[] = $attachment;
}
// 1회차: 상한 2건까지만 처리된다
$this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful();
$remaining = Attachment::withTrashed()
->whereIn('id', array_column($attachments, 'id'))
->count();
$this->assertSame(1, $remaining, '한 회차는 --limit 건까지만 처리한다.');
// 2회차: 남은 1건이 반드시 잡힌다 (커서 밀림으로 건너뛰지 않는다)
$this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful();
foreach ($attachments as $attachment) {
$this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]);
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path)
);
}
}
}
@@ -0,0 +1,129 @@
/**
* E2E: 게시판 환경설정 > 기본 설정 — "삭제 첨부 영구 정리" (공개 #115)
*
* @scenario board-settings-attachment-cleanup
* @effects board_settings_purge_toggle_default_off, board_settings_purge_keys_declared
*
* 배경: 삭제한 첨부의 파일을 보존기간 경과 후 완전히 파기하는 운영자 스위치다. 저장 payload
* 빌더가 이 카테고리를 싣지 않아 "저장 200 인데 값이 사라지던" 결함이 있었고, 그 증상은
* 응답이 정상이라 화면만 봐서는 드러나지 않았다. 단위 테스트는 레이아웃 JSON 의 바인딩만
* 보므로, 실제 요청 본문에 카테고리가 실리는지는 브라우저로만 확인된다.
*
* 검증:
* 1. 기본 설정 탭에 토글·보존기간 필드가 번역문과 함께 마운트된다
* 2. 토글은 기본 꺼짐으로 렌더된다 (사용자 파일을 지우는 기능의 인수 기준)
* 3. 값을 바꿔 저장하면 요청 본문에 `attachment_settings` 가 실제로 실린다
*
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
// 탭 판정은 `_global.activeBoardSettingsTab || query.tab || 'general'` 이다.
// 다른 값을 실으면 탭 콘텐츠 자체가 렌더되지 않아 섹션을 찾을 수 없다.
const SETTINGS_URL = '/admin/boards/settings?tab=general';
const SECTION = '#attachment_cleanup_section';
const TOGGLE_ROW = '#attachment_purge_enabled_field';
const RETENTION_ROW = '#attachment_purge_retention_days_field';
/** 설정 저장 PUT 인지 판정한다. */
const isSettingsPut = (url: string, method: string) => url.includes('/settings') && method === 'PUT';
/**
* 보존기간 값을 바꿔 저장하고, 요청 본문을 돌려준다.
*
* 응답 상태까지 확인한다 — 요청만 관찰하면 서버가 4xx 로 거부해도 단언이 통과해
* "저장됐다" 는 잘못된 결론이 남는다.
*
* @param page 대상 페이지
* @param input 보존기간 입력
* @param saveButton 저장 버튼
* @param value 채워 넣을 값
* @returns 저장 요청 본문
*/
async function save(
page: import('@playwright/test').Page,
input: import('@playwright/test').Locator,
saveButton: import('@playwright/test').Locator,
value: string,
): Promise<Record<string, unknown>> {
await input.fill(value);
await expect(saveButton).toBeEnabled({ timeout: 20_000 });
const requestPromise = page.waitForRequest(
(req) => isSettingsPut(req.url(), req.method()),
{ timeout: 20_000 },
);
const responsePromise = page.waitForResponse(
(res) => isSettingsPut(res.url(), res.request().method()),
{ timeout: 20_000 },
);
await saveButton.click();
const payload = (await requestPromise).postDataJSON() as Record<string, unknown>;
expect((await responsePromise).status()).toBe(200);
return payload;
}
test.describe('게시판 환경설정 — 삭제 첨부 영구 정리 (#115)', () => {
test('토글·보존기간 필드가 번역문과 함께 마운트되고 기본 꺼짐이다', async ({
page,
settingsToken,
}) => {
await authenticatePage(page, settingsToken);
await page.goto(SETTINGS_URL);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(SECTION)).toBeAttached({ timeout: 20_000 });
await expect(page.locator(TOGGLE_ROW)).toBeAttached();
await expect(page.locator(RETENTION_ROW)).toBeAttached();
const text = (await page.locator(SECTION).innerText()).trim();
expect(text).not.toContain('$t:');
expect(text).not.toContain('purge_enabled');
const toggle = page.locator(TOGGLE_ROW).locator('input[type="checkbox"]').first();
expect(await toggle.isChecked()).toBe(false);
});
test('저장 요청 본문에 attachment_settings 카테고리가 실린다', async ({
page,
settingsToken,
}) => {
await authenticatePage(page, settingsToken);
await page.goto(SETTINGS_URL);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(RETENTION_ROW)).toBeAttached({ timeout: 20_000 });
const input = page.locator(RETENTION_ROW).locator('input').first();
const saveButton = page.locator('#footer_save_button');
const original = (await input.inputValue()) || '30';
// 현재값을 그대로 채우면 hasChanges 가 서지 않아 저장 버튼이 비활성인 채로 남고,
// PUT 이 영원히 오지 않아 테스트가 타임아웃한다. 반드시 다른 값으로 바꾼다.
const target = original === '15' ? '20' : '15';
const payload = await save(page, input, saveButton, target);
// 카테고리 자체가 빠지면 서버는 200 을 돌려주고 값만 조용히 사라진다.
expect(payload).toHaveProperty('attachment_settings');
// number 입력의 값은 와이어에 문자열로 실리고(서버가 정수로 캐스팅한다) —
// 여기서 검증할 것은 표현 타입이 아니라 "그 값이 실제로 실렸는가" 다.
expect(
Number((payload.attachment_settings as Record<string, unknown>).purge_retention_days),
).toBe(Number(target));
// 요청 본문만 보면 서버가 그 값을 버려도 통과한다. 새로고침 후 값이 살아 있어야
// "실제로 저장됐다" 가 증명된다 (F4 가 정확히 이 지점에서 200 인 채 값을 잃었다).
await page.reload();
await expect(input).toHaveValue(target, { timeout: 20_000 });
// 검증 때문에 운영 설정이 바뀐 채로 남지 않도록 원래 값으로 되돌린다.
await save(page, input, saveButton, original);
await page.reload();
await expect(input).toHaveValue(original, { timeout: 20_000 });
});
});
@@ -0,0 +1,60 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
use Modules\Sirsoft\Board\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 게시판 참조 소스 등록 리스너 테스트
*
* 이 리스너가 빠지면 게시글에만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이 되므로,
* 구독 형식(filter)과 append 계약을 회귀로 고정합니다.
*/
class Ckeditor5ReferenceSourcesListenerTest extends ModuleTestCase
{
/**
* @effects reference_source_listener_subscribes_as_filter
*/
#[Test]
public function it_subscribes_to_the_reference_source_filter_hook(): void
{
$hooks = Ckeditor5ReferenceSourcesListener::getSubscribedHooks();
$this->assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks);
$this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']);
$this->assertSame('addBoardSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']);
}
/**
* @effects reference_source_listener_appends_without_dropping
*/
#[Test]
public function it_appends_board_sources_while_preserving_existing_ones(): void
{
$existing = [['table' => 'mail_templates', 'columns' => ['body']]];
$result = (new Ckeditor5ReferenceSourcesListener)->addBoardSources($existing);
$this->assertContains($existing[0], $result);
$this->assertContains(['table' => 'board_posts', 'columns' => ['content']], $result);
}
/**
* 선언 형태가 계약(table/columns)을 지켜야 플러그인 판정부가 해석할 수 있다.
*
* @effects reference_source_listener_schema_contract
*/
#[Test]
public function appended_sources_follow_the_declared_schema(): void
{
foreach ((new Ckeditor5ReferenceSourcesListener)->addBoardSources([]) as $source) {
$this->assertArrayHasKey('table', $source);
$this->assertArrayHasKey('columns', $source);
$this->assertIsString($source['table']);
$this->assertIsArray($source['columns']);
$this->assertNotEmpty($source['columns']);
}
}
}
@@ -0,0 +1,74 @@
feature: 게시판 첨부 정리 (Board Attachment Cleanup)
description: |
두 종류의 잔존 첨부를 회수한다. 공개 이슈 #115 동류 결함.
1. 임시 첨부 (상시 실행) — 글쓰기 폼에서 올린 뒤 저장 없이 이탈해 `temp_key` 만 남은 행.
연결 시점에 본경로로 옮겨지므로 `temp_key` 잔존 = 끝내 연결되지 않음이 확정이라
오탐 여지가 구조적으로 없다. 운영 데이터가 아닌 폼 세션 부산물이므로 토글이 없다.
2. 소프트 삭제 첨부 영구 정리 (운영자 옵트인) — 첨부 삭제는 휴지통 복원을 위해 소프트
삭제만 수행하므로 물리 파일이 남는다. 사용자 파일을 실제로 파기하므로 기본 꺼짐이며,
보존기간은 휴지통 복원 가능 기간과 같게 유지해야 한다.
삭제 순서는 파일 먼저, 기록 나중. 행마다 disk 가 다를 수 있어 그 행의 disk 를 향해 지운다.
# axes 는 "한 첨부 행의 처리 여부를 가르는 입력" 만 둔다. 실행 흐름(dry-run / 보존기간 해석)은
# 판정과 직교하므로 sub_flows 로 분리했다 — 한 축으로 묶으면 존재하지 않는 조합이 생긴다.
axes:
attachment_state: [temp_unlinked, linked, soft_deleted]
age: [within_retention, past_retention]
purge_toggle: [off, on]
exclusions:
- { attachment_state: temp_unlinked, purge_toggle: on, reason: "임시 첨부 정리는 영구 정리 토글과 무관하게 상시 실행된다" }
- { attachment_state: linked, purge_toggle: on, reason: "게시글에 연결된 첨부는 어느 파트의 대상도 아니다" }
- { attachment_state: linked, age: within_retention, reason: "연결 첨부는 나이와 무관하게 대상이 아니다 — 경과분 1건으로 충분" }
- { attachment_state: soft_deleted, purge_toggle: off, age: within_retention, reason: "토글이 꺼져 있으면 보존기간 판정에 도달하지 않는다" }
sub_flows:
- id: dry_run
description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로
effects:
- board_prune_dry_run_no_delete
- id: retention_guard
description: 보존기간 1일 미만 자기 차단 (방금 올린 임시 첨부까지 지우는 사고 방지)
effects:
- board_temp_prune_days_guard
- id: settings_surface
description: 화면 바인딩 키가 저장 카테고리·기본값·서버 검증과 일치하는지
effects:
- board_settings_purge_toggle_default_off
- board_settings_purge_keys_declared
- id: temp_directory_hygiene
description: |
파일을 지운 뒤 남는 temp 디렉토리 처리. 비워졌을 때만 지우고, 다른 글의 파일이 남아
있으면 그대로 둔다 (디렉토리째 지우면 아직 작성 중인 글의 첨부가 사라진다).
effects:
- board_temp_prune_removes_empty_directory
- board_temp_prune_keeps_directory_with_remaining_files
- id: batch_limit_boundary
description: |
대상이 한 회차 상한(--limit)을 넘을 때. 처리하며 행을 지우는 순회이므로, 남은 대상이
다음 회차에서 반드시 잡혀야 적체가 수렴한다.
effects:
- board_temp_prune_processes_all_across_runs
- id: reference_source_registry
description: 게시글 본문을 에디터 이미지 참조 소스로 등록하는 리스너
effects:
- reference_source_listener_subscribes_as_filter
- reference_source_listener_appends_without_dropping
- reference_source_listener_schema_contract
effects:
- board_temp_prune_deletes_file_and_record
- board_temp_prune_keeps_within_retention
- board_temp_prune_skips_linked
- board_purge_gate_off_by_default
- board_purge_gate_on
- board_purge_keeps_restorable_window
test_files:
- modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
- modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts
- modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts
@@ -17,6 +17,8 @@
- 리뷰 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 상품·카테고리 이미지와 동일하게 리뷰 이미지도 업로드 시점에 변환(압축·리사이즈·포맷 변경)할 수 있습니다. (#96 @lyg-kaban 님께서 건의해주셨습니다.)
- 상품·카테고리·리뷰 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 화면에는 CDN 직접 주소로 서빙할 수 있습니다. 기본정보 탭의 「공개 자산 스토리지」에서 이 모듈만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시·삭제됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 상품 등록 중 이미지만 올리고 저장하지 않은 채 나간 임시 이미지를 매일 자동 정리합니다. 파일을 지운 뒤 남는 빈 폴더까지 함께 정리하므로 저장소에 흔적이 남지 않습니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다.
### Changed
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
@@ -24,6 +26,11 @@
### Fixed
#### 카테고리 이미지
- 카테고리를 삭제해도 그 카테고리의 이미지 파일이 서버에 그대로 남던 문제를 수정했습니다. 상품 삭제와 동일하게 파일까지 함께 삭제됩니다.
- 카테고리 등록·수정 화면에서 이미지를 목록에서 빼도 화면에 그대로 남아 저장되던 문제를 수정했습니다.
#### 삭제된 PG·현금영수증 발급사
- 결제수단에 지정한 PG 사의 플러그인을 삭제해도 그 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 이전에는 구매자가 그 수단을 고르면 결제창이 뜨지 않은 채 주문완료로 넘어갔습니다. 이제 해당 결제수단은 주문서에서 제외되고, 관리자 주문설정 화면에는 「지정 PG 삭제됨」 표시가 붙습니다. 살아 있는 PG 로 다시 지정하면 즉시 복구되며, 주문서를 거치지 않고 직접 주문을 시도하는 경우에도 안내와 함께 거절됩니다.
@@ -69,6 +76,8 @@
- 같은 모달의 "다운로드 기록" 목록이 항상 비어 보이던 문제를 수정했습니다. 이제 누가 언제 몇 번째로 내려받았는지 기록이 표시됩니다.
- 같은 모달의 "처리중"·"실패"·"만료" 상태 배지에서 아이콘이 표시되지 않던 문제를 수정했습니다.
- 관리자 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. 주문·상품·쿠폰·배송정책·마일리지 내역·리뷰 목록에 적용됩니다. 쿠폰 적용 대상 고르기처럼 여러 페이지에 걸쳐 선택하는 화면은 종전대로 선택이 유지됩니다.
- 환경설정의 배송·환불 설정 화면과 상품평 목록에서 켜기/끄기 스위치와 상태 배지가 레이아웃 편집기의 편집 대상으로 선택되지 않던 문제를 수정했습니다.
- 배송 국가·배송 유형·환불 사유·추가 배송비 목록에서 각 행이 같은 HTML 식별자를 공유하던 문제를 수정했습니다.
#### 기타
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultCurrencyOnRegisterListener;
use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultShippingCountryOnRegisterListener;
use Modules\Sirsoft\Ecommerce\Listeners\CategoryActivityLogListener;
use Modules\Sirsoft\Ecommerce\Listeners\CategoryTreeCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Ecommerce\Listeners\CouponActivityLogListener;
use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener;
use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener;
@@ -2143,6 +2144,7 @@ class Module extends AbstractModule
InjectAppConfigDeviceListener::class,
IssueCashReceiptOnDepositListener::class,
PurgeCashReceiptIdentifierListener::class,
Ckeditor5ReferenceSourcesListener::class,
];
}
@@ -2178,6 +2180,14 @@ class Module extends AbstractModule
// cart_expiry_days < 1 시 커맨드 내부 self-guard 로 비활성 (별도 토글 없음)
'enabled_config' => null,
],
[
'command' => 'sirsoft-ecommerce:prune-temp-product-images',
'schedule' => 'daily',
'description' => '미연결 임시 상품 이미지 자동 삭제',
// temp_key 가 남아 있으면 끝내 연결되지 않은 폼 세션 부산물이라 오탐 여지가 없다.
// 운영 데이터가 아니므로 별도 토글 없이 상시 동작한다 (보존기간은 커맨드 옵션).
'enabled_config' => null,
],
[
'command' => 'sirsoft-ecommerce:prune-temp-orders',
'schedule' => 'hourly',
@@ -0,0 +1,135 @@
/**
* @file adminCategoryImageRemoveExpression.test.ts
* @description 카테고리 이미지 제거 시 form 에서 실제로 빠지는지 — 레이아웃 표현식 자체를 평가한다.
*
* 배경(브라우저 실측): FileUploader 는 `onRemove` 에 **hash 우선**(`item.hash || item.id`)으로
* 식별자를 넘긴다. 레이아웃이 `img.id !== $args[0]` 로만 걸러내면 저장된 이미지(항상 hash 보유)는
* 어떤 항목도 제거되지 않아, 이미지는 서버에서 즉시 삭제됐는데 폼에는 그대로 남는다.
*
* 코어 사이트 로고(`admin-settings-logo-remove-expression`)와 같은 근본 원인이며, 같은 방식으로
* **실제 레이아웃 파일**에서 식을 꺼내 평가한다 — 액션을 테스트 안에 복사해 두면 표현식 자체는
* 한 번도 평가되지 않아 회귀를 잡지 못한다.
*
* @scenario image_state=temp_unlinked, age=within_retention
*
* @effects category_image_remove_filters_by_hash, category_image_remove_falls_back_to_id
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* 모듈 루트(module.json 기준)를 위로 훑어 찾는다.
*
* @returns 모듈 루트 절대경로
*/
function moduleRoot(): string {
let current = path.dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 10; depth++) {
if (fs.existsSync(path.join(current, 'module.json'))) {
return current;
}
current = path.dirname(current);
}
throw new Error('module.json 을 가진 모듈 루트를 찾지 못했습니다.');
}
/**
* 레이아웃 트리 전체를 훑어 조건을 만족하는 첫 노드를 찾는다.
*
* 슬롯·partial 등 children 이외의 키에도 노드가 매달리므로 모든 값을 훑는다.
*
* @param node 탐색 시작 노드
* @param predicate 노드 판정 함수
* @returns 찾은 노드 또는 null
*/
function findNode(node: unknown, predicate: (n: any) => boolean): any {
if (!node || typeof node !== 'object') return null;
if (!Array.isArray(node) && predicate(node)) return node;
for (const value of Object.values(node)) {
const found = findNode(value, predicate);
if (found) return found;
}
return null;
}
const panelForm = JSON.parse(
fs.readFileSync(
path.join(
moduleRoot(),
'resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_form.json',
),
'utf-8',
),
);
const uploader = findNode(
panelForm,
(n: any) =>
n.name === 'FileUploader'
&& Array.isArray(n.actions)
&& n.actions.some((a: any) => a.event === 'onRemove'),
);
const removeExpression: string = uploader?.actions
.filter((a: any) => a.event === 'onRemove')
.map((a: any) => a.params?.['form.images'])
.find((v: unknown) => typeof v === 'string');
/**
* `{{...}}` 바인딩 식을 주어진 컨텍스트로 평가한다.
*
* @param expression 레이아웃 바인딩 식
* @param context 평가 컨텍스트 (_local, $args)
* @returns 평가 결과
*/
function evaluateBinding(expression: string, context: { _local: any; $args: unknown[] }): unknown {
const body = expression.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
return new Function('_local', '$args', `return (${body});`)(context._local, context.$args);
}
const SAVED_IMAGE = { id: 101, hash: 'abc123', original_filename: 'category1.png' };
const OTHER_IMAGE = { id: 202, hash: 'def456', original_filename: 'category2.png' };
describe('카테고리 이미지 제거 — 레이아웃 표현식 평가', () => {
it('onRemove 식이 레이아웃에 선언되어 있다', () => {
expect(typeof removeExpression).toBe('string');
});
it('hash 로 통지된 항목이 form 에서 제거된다', () => {
const result = evaluateBinding(removeExpression, {
_local: { form: { images: [SAVED_IMAGE, OTHER_IMAGE] } },
$args: [SAVED_IMAGE.hash],
}) as any[];
expect(result.map((item) => item.id)).toEqual([OTHER_IMAGE.id]);
});
it('hash 가 없는 항목은 id 로도 제거된다', () => {
const idOnly = { id: 303, original_filename: 'no-hash.png' };
const result = evaluateBinding(removeExpression, {
_local: { form: { images: [idOnly, OTHER_IMAGE] } },
$args: [idOnly.id],
}) as any[];
expect(result.map((item) => item.id)).toEqual([OTHER_IMAGE.id]);
});
it('대상이 아닌 식별자면 아무것도 제거하지 않는다', () => {
const result = evaluateBinding(removeExpression, {
_local: { form: { images: [SAVED_IMAGE, OTHER_IMAGE] } },
$args: ['nonexistent-hash'],
}) as any[];
expect(result).toHaveLength(2);
});
});
@@ -6,6 +6,9 @@
* - _tab_shipping.json (배송설정 기본 + 배송가능국가 + 도서산간)
* - _shipping_country_table.json (국가 테이블)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
// 레이아웃 JSON 임포트
@@ -16,6 +19,40 @@ import countryCards from '../../../layouts/admin/partials/admin_ecommerce_settin
import disableIntlModal from '../../../layouts/admin/partials/admin_ecommerce_settings/_disable_international_shipping_modal.json';
// ── 헬퍼 함수 ──────────────────────────────────────────
/**
* 이 화면을 렌더하는 admin 템플릿의 매니페스트가 선언한 컴포넌트 종류를 읽는다.
*
* 레이아웃 노드의 `type` 은 렌더러가 props 전달 범위를 고르는 기준이라
* 매니페스트 선언과 어긋나면 `editorAttrs` 가 제거돼 레이아웃 편집기에서 표식을 잃는다.
* 바인딩 방식(자동/수동)과는 무관한 값이다.
*
* @param name 컴포넌트 이름
* @returns 매니페스트가 선언한 type
*/
function manifestType(name: string): string {
let current = path.dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 10; depth++) {
if (fs.existsSync(path.join(current, 'artisan'))) break;
current = path.dirname(current);
}
const manifest = JSON.parse(
fs.readFileSync(
path.join(current, 'templates/_bundled/sirsoft-admin_basic/components.json'),
'utf-8',
),
);
for (const [kind, entries] of Object.entries<any>(manifest.components ?? {})) {
if ((entries as any[]).some((entry) => entry.name === name)) {
return kind;
}
}
throw new Error(`매니페스트에 ${name} 선언이 없습니다.`);
}
function flattenAll(node: any): any[] {
const result: any[] = [];
if (!node) return result;
@@ -123,14 +160,15 @@ describe('배송설정 탭 콘텐츠 (_tab_shipping.json)', () => {
expect(select.props?.options).toContain('is_active');
});
it('해외배송 Toggle이 수동 바인딩(basic + checked + actions)으로 구성되어 있다', () => {
it('해외배송 Toggle이 수동 바인딩(checked + actions)으로 구성되어 있다', () => {
const intlField = findById(allNodes, 'international_shipping_field');
expect(intlField).toBeDefined();
const toggles = flattenAll(intlField).filter((n: any) => n.name === 'Toggle');
expect(toggles.length).toBeGreaterThan(0);
const toggle = toggles[0];
// 수동 바인딩: type=basic, checked prop, actions 있음
expect(toggle.type).toBe('basic');
// 수동 바인딩의 실체는 checked prop + actions 다. 노드 type 은 바인딩 방식과 무관하며
// 매니페스트 선언을 따라야 한다(어긋나면 레이아웃 편집기에서 표식을 잃는다).
expect(toggle.type).toBe(manifestType('Toggle'));
expect(toggle.props?.checked).toContain('international_shipping_enabled');
expect(toggle.actions).toBeDefined();
expect(toggle.actions.length).toBeGreaterThanOrEqual(2);
@@ -359,7 +397,7 @@ describe('배송가능국가 테이블 (_shipping_country_table.json)', () => {
});
it('Tbody에 iteration이 있다', () => {
const iterNode = findById(allNodes, 'country_row');
const iterNode = findById(allNodes, 'country_row_{{countryIndex}}');
expect(iterNode).toBeDefined();
expect(iterNode.iteration).toBeDefined();
expect(iterNode.iteration.source).toContain('available_countries');
@@ -496,7 +534,7 @@ describe('바인딩 패턴 일관성 검증', () => {
it('해외배송 Toggle이 수동 바인딩이며 ON/OFF 모두 hasChanges를 설정한다', () => {
const intlField = findById(allNodes, 'international_shipping_field');
const toggle = flattenAll(intlField).find((n: any) => n.name === 'Toggle');
expect(toggle.type).toBe('basic');
expect(toggle.type).toBe(manifestType('Toggle'));
expect(toggle.props?.checked).toContain('international_shipping_enabled');
expect(toggle.actions).toBeDefined();
// ON action에 hasChanges: true
@@ -758,7 +796,7 @@ describe('배송가능국가 모바일 카드 — 루트 구조', () => {
it('카드가 excel-card 클래스로 반복 렌더링된다', () => {
const allNodes = flattenAll(countryCards);
const card = findById(allNodes, 'country_card');
const card = findById(allNodes, 'country_card_{{countryIndex}}');
expect(card).toBeDefined();
expect(card.props?.className).toContain('excel-card');
expect(card.iteration).toBeDefined();
@@ -769,7 +807,7 @@ describe('배송가능국가 모바일 카드 — 루트 구조', () => {
it('iteration source에 _idx 인덱스 주입이 포함된다', () => {
const allNodes = flattenAll(countryCards);
const card = findById(allNodes, 'country_card');
const card = findById(allNodes, 'country_card_{{countryIndex}}');
expect(card.iteration.source).toContain('_idx');
expect(card.iteration.source).toContain('.map(');
});
@@ -277,7 +277,7 @@ describe('배송유형 카드뷰 구조', () => {
const allNodes = flattenAll(typeCards);
it('iteration으로 _local.form.shipping.types를 순회한다', () => {
const card = allNodes.find((n: any) => n.id === 'shipping_type_card');
const card = allNodes.find((n: any) => n.id === 'shipping_type_card_{{tIdx}}');
expect(card).toBeDefined();
expect(card.iteration.source).toContain('_local.form?.shipping?.types');
expect(card.iteration.item_var).toBe('type');
@@ -2483,7 +2483,7 @@
"text": "$t:sirsoft-ecommerce.admin.product_review.detail.seller_reply.title"
},
{
"type": "basic",
"type": "composite",
"name": "Badge",
"props": {
"color": "{{row.has_reply_badge_color}}",
@@ -90,7 +90,7 @@
"if": "{{!_local.isSaving}}",
"props": {
"name": "save",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -141,7 +141,7 @@
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
"className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -567,7 +567,7 @@
"handler": "setState",
"params": {
"target": "local",
"form.images": "{{(_local.form?.images ?? []).filter(img => img.id !== $args[0])}}"
"form.images": "{{(_local.form?.images ?? []).filter(img => (img.hash || img.id) !== $args[0])}}"
}
}
]
@@ -627,7 +627,7 @@
"props": {
"name": "parent_id",
"placeholder": "$t:sirsoft-ecommerce.admin.category.form.parent_category_placeholder",
"options": "{{(() => { const result = []; const flatten = (items, path = [], depth = 0) => { if (!items || !Array.isArray(items) || items.length === 0) return; for (const item of items) { if (!item || !item.id) continue; const currentPath = [...path, $localized(item.name)]; result.push({ value: item.id, label: currentPath.join(' > '), id: item.id }); if (item.children && Array.isArray(item.children) && item.children.length > 0 && depth < 2) { flatten(item.children, currentPath, depth + 1); } } }; flatten(categories?.data?.data); const filtered = result.filter(c => c && c.id && (_global.panelMode === 'create' || c.id !== _global.selectedCategoryId)); return [{ value: '', label: $t('sirsoft-ecommerce.admin.category.form.parent_top_level') }, ...filtered]; })()}}",
"options": "{{(() => { const result = []; const flatten = (items, path = [], depth = 0) => { if (!items || !Array.isArray(items) || items.length === 0) return; for (const item of items) { if (!item || !item.id) continue; const currentPath = [...path, $localized(item.name)]; result.push({ value: item.id, label: currentPath.join(' > '), id: item.id }); if (item.children && Array.isArray(item.children) && item.children.length > 0 && depth < 2) { flatten(item.children, currentPath, depth + 1); } } }; flatten(categories?.data?.data); const filtered = result.filter(c => c && c.id && (_global.panelMode === 'create' || c.id !== _global.selectedCategoryId)); return [{ value: '', label: $t('sirsoft-ecommerce.admin.category.form.parent_top_level') }, ...filtered]; })() ?? []}}",
"isMulti": false,
"isClearable": false,
"isSearchable": true,
@@ -10,7 +10,7 @@
},
"children": [
{
"id": "refund_reason_card",
"id": "refund_reason_card_{{rIdx}}",
"type": "basic",
"name": "Div",
"iteration": {
@@ -63,7 +63,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{reason.is_active ?? false}}",
@@ -604,7 +604,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{reason.is_active ?? false}}",
@@ -8,7 +8,7 @@
"name": "Div",
"children": [
{
"id": "carrier_card",
"id": "carrier_card_{{$cIdx}}",
"type": "basic",
"name": "Div",
"iteration": {
@@ -69,7 +69,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{$carrier.is_active ?? false}}",
@@ -677,7 +677,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{$carrier.is_active ?? false}}",
@@ -8,7 +8,7 @@
"name": "Div",
"children": [
{
"id": "country_card",
"id": "country_card_{{countryIndex}}",
"type": "basic",
"name": "Div",
"iteration": {
@@ -76,7 +76,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{country.is_active ?? false}}",
@@ -108,7 +108,7 @@
"name": "Tbody",
"children": [
{
"id": "country_row",
"id": "country_row_{{countryIndex}}",
"type": "basic",
"name": "Tr",
"iteration": {
@@ -166,7 +166,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{country.is_active ?? false}}",
@@ -10,7 +10,7 @@
},
"children": [
{
"id": "shipping_type_card",
"id": "shipping_type_card_{{tIdx}}",
"type": "basic",
"name": "Div",
"iteration": {
@@ -63,7 +63,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{type.is_active ?? false}}",
@@ -567,7 +567,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{$type.is_active ?? false}}",
@@ -96,7 +96,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_local.form?.shipping?.international_shipping_enabled ?? false}}",
@@ -207,7 +207,7 @@
},
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"label": "$t:sirsoft-ecommerce.admin.shipping_policy.form.country_active",
@@ -47,7 +47,7 @@
"name": "Div",
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"label": "$t:sirsoft-ecommerce.admin.shipping_policy.form.extra_fee_enabled",
@@ -90,7 +90,7 @@
"name": "Div",
"children": [
{
"type": "basic",
"type": "composite",
"name": "Toggle",
"props": {
"label": "$t:sirsoft-ecommerce.admin.shipping_policy.form.extra_fee_multiply",
@@ -435,7 +435,7 @@
"portable": {
"children": [
{
"id": "extra_fee_cards",
"id": "extra_fee_cards_{{feeIdx}}",
"type": "basic",
"name": "Div",
"iteration": {
@@ -0,0 +1,92 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
/**
* 미연결 임시 상품 이미지 정리 커맨드
*
* 상품 등록 폼에서 업로드만 하고 저장 없이 이탈해 남은 임시 이미지(파일 + 기록)를 정리합니다.
* 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 "끝내 연결되지 않았다" 는
* 뜻이고, 그래서 별도 운영자 토글 없이 상시 동작합니다 (운영 데이터가 아닌 폼 세션 부산물).
*
* @example php artisan sirsoft-ecommerce:prune-temp-product-images --dry-run
* @example php artisan sirsoft-ecommerce:prune-temp-product-images --days=1
*/
class PruneTempProductImagesCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'sirsoft-ecommerce:prune-temp-product-images
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--days=2 : 임시 이미지 보존기간(일)}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '상품에 연결되지 않은 채 방치된 임시 상품 이미지를 정리합니다.';
/**
* @param ProductImageService $productImageService 상품 이미지 서비스
*/
public function __construct(
protected ProductImageService $productImageService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
$days = (int) $this->option('days');
// 보존기간 0 이하 = 방금 올린 임시 이미지까지 지우게 되므로 차단한다.
if ($days < 1) {
$this->info('임시 이미지 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
return Command::SUCCESS;
}
$limit = max(1, (int) $this->option('limit'));
$isDryRun = (bool) $this->option('dry-run');
$result = $this->productImageService->pruneTempUploads($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('PruneTempProductImagesCommand: 임시 상품 이미지 정리 완료', [
'days' => $days,
'limit' => $limit,
'scanned' => $result['scanned'],
'deleted' => $result['deleted'],
'failed' => $result['failed'],
]);
return Command::SUCCESS;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 상품 설명·공통 안내를 CKEditor5 이미지 참조 스캔 대상으로 등록하는 리스너
*
* `sirsoft-ckeditor5.image.filter_reference_sources` 필터 훅에 상품 상세 설명과
* 상품 공통 정보 본문 컬럼을 덧붙입니다. 등록하지 않으면 상품 설명에만 쓰이는
* 이미지가 "미참조" 로 판정돼 자동 정리 대상이 됩니다.
*
* 테이블명 문자열만 덧붙이며 DB 에 접근하지 않습니다.
*/
class Ckeditor5ReferenceSourcesListener implements HookListenerInterface
{
/**
* 구독할 훅 목록 반환
*
* @return array<string, mixed>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ckeditor5.image.filter_reference_sources' => [
'method' => 'addEcommerceSources',
'priority' => 10,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트를 처리합니다.
*
* Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로
* 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다.
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void {}
/**
* 이커머스 참조 소스를 추가합니다.
*
* @param array $sources 기존 참조 소스 목록
* @return array 이커머스 소스가 추가된 목록
*/
public function addEcommerceSources(array $sources): array
{
$sources[] = ['table' => 'ecommerce_products', 'columns' => ['description']];
$sources[] = ['table' => 'ecommerce_product_common_infos', 'columns' => ['content']];
return $sources;
}
}
@@ -11,6 +11,7 @@ use Modules\Sirsoft\Ecommerce\Console\Commands\ExpireMileageCommand;
use Modules\Sirsoft\Ecommerce\Console\Commands\NotifyExpiringMileageCommand;
use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredCartsCommand;
use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredTempOrdersCommand;
use Modules\Sirsoft\Ecommerce\Console\Commands\PruneTempProductImagesCommand;
use Modules\Sirsoft\Ecommerce\Console\Commands\ReconcileMileageBalanceCommand;
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
use Modules\Sirsoft\Ecommerce\Repositories\BrandRepository;
@@ -199,6 +200,7 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
NotifyExpiringMileageCommand::class,
PruneExpiredCartsCommand::class,
PruneExpiredTempOrdersCommand::class,
PruneTempProductImagesCommand::class,
ReconcileMileageBalanceCommand::class,
];
@@ -90,6 +90,14 @@ class CategoryImageRepository implements CategoryImageRepositoryInterface
return $image?->delete() ?? false;
}
/**
* {@inheritDoc}
*/
public function deleteByCategoryId(int $categoryId): int
{
return $this->model->where('category_id', $categoryId)->delete();
}
/**
* {@inheritDoc}
*/
@@ -13,7 +13,7 @@ interface CategoryImageRepositoryInterface
/**
* ID로 이미지 조회
*
* @param int $id 이미지 ID
* @param int $id 이미지 ID
* @return CategoryImage|null
*/
public function findById(int $id): ?CategoryImage;
@@ -21,7 +21,7 @@ interface CategoryImageRepositoryInterface
/**
* 해시로 이미지 조회
*
* @param string $hash 이미지 해시
* @param string $hash 이미지 해시
* @return CategoryImage|null
*/
public function findByHash(string $hash): ?CategoryImage;
@@ -29,8 +29,8 @@ interface CategoryImageRepositoryInterface
/**
* 카테고리의 모든 이미지 조회
*
* @param int $categoryId 카테고리 ID
* @param string|null $collection 컬렉션 필터
* @param int $categoryId 카테고리 ID
* @param string|null $collection 컬렉션 필터
* @return Collection
*/
public function getByCategoryId(int $categoryId, ?string $collection = null): Collection;
@@ -38,8 +38,8 @@ interface CategoryImageRepositoryInterface
/**
* temp_key로 임시 이미지 조회
*
* @param string $tempKey 임시 키
* @param string|null $collection 컬렉션 필터
* @param string $tempKey 임시 키
* @param string|null $collection 컬렉션 필터
* @return Collection
*/
public function getByTempKey(string $tempKey, ?string $collection = null): Collection;
@@ -47,7 +47,7 @@ interface CategoryImageRepositoryInterface
/**
* 카테고리 이미지 생성
*
* @param array $data 이미지 데이터
* @param array $data 이미지 데이터
* @return CategoryImage
*/
public function create(array $data): CategoryImage;
@@ -55,8 +55,8 @@ interface CategoryImageRepositoryInterface
/**
* 이미지 수정
*
* @param int $id 이미지 ID
* @param array $data 수정할 데이터
* @param int $id 이미지 ID
* @param array $data 수정할 데이터
* @return CategoryImage
*/
public function update(int $id, array $data): CategoryImage;
@@ -64,16 +64,26 @@ interface CategoryImageRepositoryInterface
/**
* 이미지 삭제
*
* @param int $id 이미지 ID
* @param int $id 이미지 ID
* @return bool
*/
public function delete(int $id): bool;
/**
* 카테고리에 속한 이미지 행을 일괄 삭제
*
* 물리 파일 삭제는 호출한 Service 가 담당합니다 (행마다 disk 가 다를 수 있음).
*
* @param int $categoryId 카테고리 ID
* @return int 삭제된 행 수
*/
public function deleteByCategoryId(int $categoryId): int;
/**
* 임시 이미지를 카테고리에 연결
*
* @param string $tempKey 임시 키
* @param int $categoryId 카테고리 ID
* @param string $tempKey 임시 키
* @param int $categoryId 카테고리 ID
* @return int 연결된 이미지 수
*/
public function linkTempImages(string $tempKey, int $categoryId): int;
@@ -81,8 +91,8 @@ interface CategoryImageRepositoryInterface
/**
* 카테고리의 최대 sort_order 조회
*
* @param int $categoryId 카테고리 ID
* @param string $collection 컬렉션명
* @param int $categoryId 카테고리 ID
* @param string $collection 컬렉션명
* @return int
*/
public function getMaxSortOrder(int $categoryId, string $collection): int;
@@ -90,8 +100,8 @@ interface CategoryImageRepositoryInterface
/**
* temp_key의 최대 sort_order 조회
*
* @param string $tempKey 임시 키
* @param string $collection 컬렉션명
* @param string $tempKey 임시 키
* @param string $collection 컬렉션명
* @return int
*/
public function getMaxSortOrderByTempKey(string $tempKey, string $collection): int;
@@ -99,7 +109,7 @@ interface CategoryImageRepositoryInterface
/**
* 순서 변경
*
* @param array<int, int> $orders 이미지 ID => sort_order 매핑
* @param array<int, int> $orders 이미지 ID => sort_order 매핑
* @return bool
*/
public function reorder(array $orders): bool;
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
/**
@@ -69,6 +70,18 @@ interface ProductImageRepositoryInterface
*/
public function delete(int $id): bool;
/**
* 상품에 연결되지 않은 채 방치된 임시 이미지를 오래된 순으로 조회
*
* 상품 등록 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채
* `product_id` 가 비어 있는 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @return Collection<int, ProductImage> 임시 이미지 목록 (created_at 오름차순)
*/
public function findStaleTempImages(Carbon $threshold, int $limit): Collection;
/**
* 임시 이미지를 상품에 연결
*
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Ecommerce\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface;
@@ -90,6 +91,21 @@ class ProductImageRepository implements ProductImageRepositoryInterface
return $image?->forceDelete() ?? false;
}
/**
* {@inheritDoc}
*/
public function findStaleTempImages(Carbon $threshold, int $limit): Collection
{
return $this->model->newQuery()
->whereNotNull('temp_key')
->whereNull('product_id')
->where('created_at', '<', $threshold)
->orderBy('created_at')
->orderBy('id')
->limit($limit)
->get(['id', 'temp_key', 'disk', 'path', 'created_at']);
}
/**
* {@inheritDoc}
*/
@@ -248,6 +248,49 @@ class CategoryImageService
return $result;
}
/**
* 카테고리에 속한 이미지를 파일까지 함께 삭제합니다.
*
* 카테고리 삭제 흐름에서 호출합니다. 단건 삭제(delete)와 달리 남은 이미지 재정렬이
* 필요 없으므로(카테고리 자체가 사라짐) 재정렬 없이 파일 → 행 순으로 정리합니다.
*
* 행마다 disk 가 다를 수 있으므로 파일 삭제는 각 행의 disk 를 향해 수행합니다.
*
* @param int $categoryId 카테고리 ID
* @return int 삭제된 이미지 행 수
*/
public function deleteByCategoryId(int $categoryId): int
{
$images = $this->repository->getByCategoryId($categoryId);
if ($images->isEmpty()) {
return 0;
}
foreach ($images as $image) {
HookManager::doAction('sirsoft-ecommerce.category-image.before_delete', $image);
$rowStorage = $this->storageForRow($image->disk);
if ($rowStorage->exists('images', $image->path)) {
$rowStorage->delete('images', $image->path);
}
}
$deleted = $this->repository->deleteByCategoryId($categoryId);
Log::info('카테고리 이미지 일괄 삭제 완료', [
'category_id' => $categoryId,
'deleted' => $deleted,
]);
foreach ($images as $image) {
HookManager::doAction('sirsoft-ecommerce.category-image.after_delete', $image);
}
return $deleted;
}
/**
* 순서 변경
*
@@ -18,7 +18,8 @@ class CategoryService
{
public function __construct(
protected CategoryRepositoryInterface $repository,
protected CategoryImageRepositoryInterface $imageRepository
protected CategoryImageRepositoryInterface $imageRepository,
protected CategoryImageService $categoryImageService
) {}
/**
@@ -274,7 +275,9 @@ class CategoryService
DB::transaction(function () use ($category) {
// 카테고리 이미지 삭제 (명시적 삭제 - CASCADE 의존 금지)
$category->images()->delete();
// 물리 파일 → 행 순으로 정리한다. 행만 지우면 파일이 디스크에 영구 잔존한다
// (상품 삭제와 동일한 순서 — ProductService::delete 선례).
$this->categoryImageService->deleteByCategoryId($category->id);
// 카테고리 삭제
$this->repository->delete($category->id);
@@ -7,6 +7,7 @@ use App\Extension\HookManager;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -492,6 +493,92 @@ class ProductImageService
return ! in_array(false, $results, true);
}
/**
* 상품에 연결되지 않은 채 방치된 임시 이미지를 정리합니다.
*
* 상품 등록 폼에서 이미지를 올린 뒤 저장하지 않고 이탈하면 `temp_key` 만 남은 행과
* 그 파일이 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은
* "끝내 연결되지 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다.
*
* 파일은 행마다 기록된 disk 를 향해 지웁니다 (디스크 전환 이후 혼재 대응).
* 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다.
*
* @param int $days 보존기간(일)
* @param int $limit 한 회차에 처리할 최대 건수
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array
{
$threshold = Carbon::now()->subDays($days);
$images = $this->repository->findStaleTempImages($threshold, $limit);
$result = ['scanned' => $images->count(), 'deleted' => 0, 'failed' => 0];
if ($dryRun) {
return $result;
}
foreach ($images as $image) {
$rowStorage = $this->storageForRow($image->disk);
if ($rowStorage->exists('images', $image->path) && ! $rowStorage->delete('images', $image->path)) {
Log::warning('임시 상품 이미지 파일 삭제 실패 — 기록 보존', [
'image_id' => $image->id,
'disk' => $image->disk,
'path' => $image->path,
]);
$result['failed']++;
continue;
}
$this->repository->delete($image->id);
$result['deleted']++;
}
$this->removeEmptyTempDirectories($images);
return $result;
}
/**
* 파일을 모두 지운 temp_key 디렉토리를 정리합니다.
*
* 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
* 저장소에는 흔적이 계속 늘어납니다.
*
* 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 이미지가 limit 에 걸려 이번 회차에서
* 빠졌거나 파일 삭제에 실패한 경우 등) 삭제하지 않습니다.
*
* @param Collection $images 이번 회차에 처리한 이미지 목록
*/
private function removeEmptyTempDirectories(Collection $images): void
{
$directories = [];
foreach ($images as $image) {
$directory = dirname((string) $image->path);
if ($directory === '' || $directory === '.') {
continue;
}
$directories[$image->disk.'|'.$directory] = [$image->disk, $directory];
}
foreach ($directories as [$disk, $directory]) {
$storage = $this->storageForRow($disk);
if ($storage->files('images', $directory) !== []) {
continue;
}
$storage->deleteDirectory('images', $directory);
}
}
/**
* 상품의 이미지 목록을 조회합니다.
*
@@ -0,0 +1,186 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Console;
use App\Extension\Storage\ModuleStorageDriver;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 미연결 임시 상품 이미지 정리 커맨드 테스트
*
* `temp_key` 가 남고 `product_id` 가 비어 있는 행만 대상이어야 합니다 — 연결된 이미지가
* 섞이면 판매 중인 상품의 이미지가 사라집니다.
*/
class PruneTempProductImagesCommandTest extends ModuleTestCase
{
/**
* 임시 상품 이미지 파일과 기록을 함께 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @return ProductImage 생성된 이미지
*/
private function makeImage(array $attributes = []): ProductImage
{
$filename = uniqid('temp-product-').'.png';
$path = $attributes['path'] ?? "products/temp/tempkey/{$filename}";
(new ModuleStorageDriver('sirsoft-ecommerce', 'modules'))->put('images', $path, 'bytes');
return ProductImage::create(array_merge([
'product_id' => null,
'temp_key' => 'tempkey',
'original_filename' => $filename,
'stored_filename' => $filename,
'disk' => 'modules',
'path' => $path,
'mime_type' => 'image/png',
'file_size' => 5,
'collection' => 'main',
'sort_order' => 1,
], $attributes));
}
/**
* @scenario age=past_retention, image_state=temp_unlinked
*
* @effects ecommerce_temp_prune_deletes_file_and_record
*/
#[Test]
public function stale_temp_images_lose_both_file_and_record(): void
{
$image = $this->makeImage();
$image->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful();
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-ecommerce', 'modules'))->exists('images', $image->path)
);
$this->assertDatabaseMissing('ecommerce_product_images', ['id' => $image->id]);
}
/**
* @scenario age=within_retention, image_state=temp_unlinked
*
* @effects ecommerce_temp_prune_keeps_within_retention
*/
#[Test]
public function temp_image_within_retention_is_kept(): void
{
$image = $this->makeImage();
$this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful();
$this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]);
}
/**
* @scenario age=past_retention, image_state=linked_to_product
*
* @effects ecommerce_temp_prune_skips_linked
*/
#[Test]
public function image_linked_to_a_product_is_never_pruned(): void
{
$product = Product::factory()->create();
$image = $this->makeImage([
'product_id' => $product->id,
'temp_key' => null,
'path' => "products/{$product->product_code}/".uniqid('linked-').'.png',
]);
$image->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful();
$this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]);
}
/**
* @effects ecommerce_temp_prune_dry_run
*/
#[Test]
public function dry_run_reports_targets_without_deleting(): void
{
$image = $this->makeImage();
$image->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-ecommerce:prune-temp-product-images --dry-run')
->expectsOutputToContain('[DRY RUN]')
->assertSuccessful();
$this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]);
}
/**
* @effects ecommerce_temp_prune_days_guard
*/
#[Test]
public function retention_below_one_day_performs_no_cleanup(): void
{
$image = $this->makeImage();
$image->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-ecommerce:prune-temp-product-images --days=0')->assertSuccessful();
$this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]);
}
/**
* 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀)
*
* 파일만 지우고 디렉토리를 남기면 상품 등록 폼 세션마다 빈 디렉토리가 쌓여, 정리를
* 돌려도 저장소 흔적은 계속 늘어난다.
*
* @scenario age=past_retention, image_state=temp_unlinked
*
* @effects ecommerce_temp_prune_removes_empty_directory
*/
#[Test]
public function emptied_temp_directory_is_removed(): void
{
$image = $this->makeImage(['path' => 'products/temp/tempkey-empty-dir/'.uniqid('t-').'.png']);
$image->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful();
$storage = new ModuleStorageDriver('sirsoft-ecommerce', 'modules');
$this->assertSame(
[],
$storage->files('images', 'products/temp/tempkey-empty-dir'),
'디렉토리 안의 파일은 모두 삭제되어야 한다.'
);
$this->assertDirectoryDoesNotExist(
rtrim($storage->getBasePath('images'), '/\\').'/products/temp/tempkey-empty-dir',
'비워진 temp 디렉토리는 남기지 않는다.'
);
}
/**
* 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다.
*
* @scenario age=past_retention, image_state=temp_unlinked
*
* @effects ecommerce_temp_prune_keeps_directory_with_remaining_files
*/
#[Test]
public function temp_directory_with_remaining_files_is_kept(): void
{
$directory = 'products/temp/tempkey-partial';
$processed = $this->makeImage(['path' => $directory.'/'.uniqid('done-').'.png']);
$processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
// 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태
$storage = new ModuleStorageDriver('sirsoft-ecommerce', 'modules');
$storage->put('images', $directory.'/keep.png', 'bytes');
$this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful();
$this->assertTrue($storage->exists('images', $directory.'/keep.png'));
}
}
@@ -0,0 +1,119 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Services;
use App\Extension\Storage\ModuleStorageDriver;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Models\CategoryImage;
use Modules\Sirsoft\Ecommerce\Services\CategoryService;
use Modules\Sirsoft\Ecommerce\Services\ProductService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 카테고리 삭제 시 이미지 물리 파일까지 삭제되는지 검증 (회귀)
*
* 종전에는 `$category->images()->delete()` 로 DB 행만 지워 파일이 디스크에 영구 잔존했다.
* 같은 모듈의 상품 삭제는 파일까지 지우므로 두 경로가 비대칭이었다.
*
* @see ProductService::delete() 대칭 선례
*/
class CategoryDeleteImageFileTest extends ModuleTestCase
{
/**
* 지정 disk 에 파일과 카테고리 이미지 행을 함께 생성합니다.
*
* @param int $categoryId 카테고리 ID
* @param string $disk 행 disk
* @param string $path 이미지 경로 (images/ 하위 상대 경로)
* @return CategoryImage 생성된 행
*/
private function makeImage(int $categoryId, string $disk, string $path): CategoryImage
{
(new ModuleStorageDriver('sirsoft-ecommerce', $disk))->put('images', $path, 'image-bytes');
return CategoryImage::create([
'category_id' => $categoryId,
'original_filename' => 'category.jpg',
'stored_filename' => basename($path),
'disk' => $disk,
'path' => $path,
'mime_type' => 'image/jpeg',
'file_size' => 11,
'collection' => 'main',
'sort_order' => 0,
]);
}
/**
* @effects category_delete_removes_image_files, category_delete_removes_image_rows
*/
#[Test]
public function deleting_a_category_removes_its_image_files_and_rows(): void
{
$category = Category::create([
'name' => ['ko' => '삭제 대상 카테고리', 'en' => 'Category To Delete'],
'slug' => 'category-delete-image-file-test',
'is_active' => true,
'depth' => 0,
'path' => '',
'sort_order' => 0,
]);
$image = $this->makeImage($category->id, 'modules', 'category/delete-test/main.jpg');
$storage = new ModuleStorageDriver('sirsoft-ecommerce', 'modules');
$this->assertTrue(
$storage->exists('images', $image->path),
'사전 조건: 삭제 전에는 이미지 파일이 존재해야 한다.'
);
app(CategoryService::class)->deleteCategory($category->id);
$this->assertFalse(
$storage->exists('images', $image->path),
'카테고리를 삭제하면 이미지 물리 파일도 함께 삭제되어야 한다.'
);
$this->assertDatabaseMissing('ecommerce_category_images', ['id' => $image->id]);
$this->assertDatabaseMissing('ecommerce_categories', ['id' => $category->id]);
}
/**
* 행마다 disk 가 다른 혼재 상태에서도 각 행의 실제 저장 위치를 향해 삭제해야 한다.
*
* @effects category_delete_removes_image_files
*/
#[Test]
public function deleting_a_category_removes_image_files_across_mixed_disks(): void
{
config()->set('filesystems.disks.fake_cdn', [
'driver' => 'local',
'root' => storage_path('framework/testing/disks/fake_cdn'),
'url' => 'https://cdn.test/assets',
]);
$category = Category::create([
'name' => ['ko' => '혼재 디스크 카테고리', 'en' => 'Mixed Disk Category'],
'slug' => 'category-delete-mixed-disk-test',
'is_active' => true,
'depth' => 0,
'path' => '',
'sort_order' => 0,
]);
$local = $this->makeImage($category->id, 'modules', 'category/delete-mixed/local.jpg');
$cdn = $this->makeImage($category->id, 'fake_cdn', 'category/delete-mixed/cdn.jpg');
app(CategoryService::class)->deleteCategory($category->id);
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-ecommerce', 'modules'))->exists('images', $local->path),
'기본 디스크 행의 파일이 삭제되어야 한다.'
);
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'))->exists('images', $cdn->path),
'다른 디스크 행의 파일도 그 행의 disk 를 향해 삭제되어야 한다.'
);
}
}
@@ -0,0 +1,59 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
use Modules\Sirsoft\Ecommerce\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 이커머스 참조 소스 등록 리스너 테스트
*
* 이 리스너가 빠지면 상품 설명에만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이
* 되므로, 구독 형식(filter)과 append 계약을 회귀로 고정합니다.
*/
class Ckeditor5ReferenceSourcesListenerTest extends ModuleTestCase
{
/**
* @effects reference_source_listener_subscribes_as_filter
*/
#[Test]
public function it_subscribes_to_the_reference_source_filter_hook(): void
{
$hooks = Ckeditor5ReferenceSourcesListener::getSubscribedHooks();
$this->assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks);
$this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']);
$this->assertSame('addEcommerceSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']);
}
/**
* @effects reference_source_listener_appends_without_dropping
*/
#[Test]
public function it_appends_ecommerce_sources_while_preserving_existing_ones(): void
{
$existing = [['table' => 'mail_templates', 'columns' => ['body']]];
$result = (new Ckeditor5ReferenceSourcesListener)->addEcommerceSources($existing);
$this->assertContains($existing[0], $result);
$this->assertContains(['table' => 'ecommerce_products', 'columns' => ['description']], $result);
$this->assertContains(['table' => 'ecommerce_product_common_infos', 'columns' => ['content']], $result);
}
/**
* @effects reference_source_listener_schema_contract
*/
#[Test]
public function appended_sources_follow_the_declared_schema(): void
{
foreach ((new Ckeditor5ReferenceSourcesListener)->addEcommerceSources([]) as $source) {
$this->assertArrayHasKey('table', $source);
$this->assertArrayHasKey('columns', $source);
$this->assertIsString($source['table']);
$this->assertIsArray($source['columns']);
$this->assertNotEmpty($source['columns']);
}
}
}
@@ -7,6 +7,7 @@ use Mockery;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
use Modules\Sirsoft\Ecommerce\Services\CategoryService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
@@ -23,13 +24,20 @@ class CategoryServiceTest extends ModuleTestCase
protected $mockImageRepository;
protected $mockImageService;
protected function setUp(): void
{
parent::setUp();
$this->mockRepository = Mockery::mock(CategoryRepositoryInterface::class);
$this->mockImageRepository = Mockery::mock(CategoryImageRepositoryInterface::class);
$this->service = new CategoryService($this->mockRepository, $this->mockImageRepository);
$this->mockImageService = Mockery::mock(CategoryImageService::class);
$this->service = new CategoryService(
$this->mockRepository,
$this->mockImageRepository,
$this->mockImageService,
);
}
protected function tearDown(): void
@@ -43,7 +51,10 @@ class CategoryServiceTest extends ModuleTestCase
// ========================================
/**
* 카테고리 삭제 시 이미지가 명시적으로 삭제되는지 확인
* 카테고리 삭제 시 이미지가 파일까지 명시적으로 삭제되는지 확인
*
* 행만 지우면 물리 파일이 디스크에 영구 잔존하므로, 삭제는 파일 삭제를 포함하는
* CategoryImageService 경로를 거쳐야 한다.
*/
public function test_delete_category_deletes_images(): void
{
@@ -51,9 +62,11 @@ class CategoryServiceTest extends ModuleTestCase
$category = Mockery::mock(Category::class)->makePartial();
$category->shouldReceive('getAttribute')->with('id')->andReturn(1);
$mockImages = Mockery::mock(HasMany::class);
$mockImages->shouldReceive('delete')->once();
$category->shouldReceive('images')->once()->andReturn($mockImages);
$this->mockImageService
->shouldReceive('deleteByCategoryId')
->with(1)
->once()
->andReturn(1);
$this->mockRepository
->shouldReceive('findById')
@@ -0,0 +1,66 @@
feature: 상품 이미지 잔존물 회수 (Temp Product Image Cleanup + Category Delete)
description: |
이커머스의 이미지 잔존물 두 갈래를 회수한다. 공개 이슈 #115 동류 결함.
1. 미연결 임시 상품 이미지 — 상품 등록 폼에서 올린 뒤 저장 없이 이탈해 `temp_key` 만
남고 `product_id` 가 비어 있는 행. 연결 시점에 본경로로 옮겨지므로 오탐 여지가 없다.
운영 데이터가 아닌 폼 세션 부산물이라 토글 없이 상시 실행된다.
2. 카테고리 삭제 시 이미지 파일 잔존 (버그) — `$category->images()->delete()` 로 DB 행만
지워 파일이 디스크에 영구 잔존했다. 같은 모듈의 상품 삭제는 파일까지 지우므로 두 경로가
비대칭이었다. 파일 → 행 순서로 정리하도록 교정했다(ProductService 선례와 동일 순서).
axes:
image_state: [temp_unlinked, linked_to_product]
age: [within_retention, past_retention]
exclusions:
- { image_state: linked_to_product, age: within_retention, reason: "연결된 이미지는 나이와 무관하게 대상이 아니다 — 경과분 1건으로 충분" }
sub_flows:
- id: dry_run
description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로
effects:
- ecommerce_temp_prune_dry_run
- id: retention_guard
description: 보존기간 1일 미만 자기 차단 (방금 올린 임시 이미지까지 지우는 사고 방지)
effects:
- ecommerce_temp_prune_days_guard
- id: temp_directory_hygiene
description: |
파일을 지운 뒤 남는 temp 디렉토리 처리. 비워졌을 때만 지우고, 다른 폼 세션의 파일이
남아 있으면 그대로 둔다 (디렉토리째 지우면 아직 작성 중인 상품의 이미지가 사라진다).
effects:
- ecommerce_temp_prune_removes_empty_directory
- ecommerce_temp_prune_keeps_directory_with_remaining_files
- id: category_delete
description: |
카테고리 삭제가 이미지 파일까지 지우는지 (회귀). 행마다 disk 가 다른 혼재 상태에서도
각 행의 실제 저장 위치를 향해 지워야 한다.
effects:
- category_delete_removes_image_files
- category_delete_removes_image_rows
- id: category_image_remove_ui
description: |
카테고리 폼에서 이미지를 뺐을 때 폼 상태에서도 빠지는지. FileUploader 는 hash 우선으로
식별자를 넘기므로 id 로만 걸러내면 서버에서는 지워졌는데 폼에는 남는다.
effects:
- category_image_remove_filters_by_hash
- category_image_remove_falls_back_to_id
- id: reference_source_registry
description: 상품 설명·공통 안내를 에디터 이미지 참조 소스로 등록하는 리스너
effects:
- reference_source_listener_subscribes_as_filter
- reference_source_listener_appends_without_dropping
- reference_source_listener_schema_contract
effects:
- ecommerce_temp_prune_deletes_file_and_record
- ecommerce_temp_prune_keeps_within_retention
- ecommerce_temp_prune_skips_linked
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneTempProductImagesCommandTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CategoryDeleteImageFileTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminCategoryImageRemoveExpression.test.ts
@@ -11,6 +11,10 @@
- 미발행(초안) 페이지에 붙은 이미지 첨부의 미리보기가 주소만 알면 누구에게나 열리던 문제를 수정했습니다. 다운로드는 이미 발행 상태를 확인하고 있었지만 미리보기는 확인하지 않아, 발행 전 콘텐츠의 이미지가 새어 나갈 수 있었습니다. 이제 미리보기도 다운로드와 같은 기준을 적용해, 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한이 있는 관리자만 미리볼 수 있습니다. 편집 중인 초안의 썸네일은 편집 권한이 있는 관리자에게 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
- 페이지 일괄 발행/발행취소와 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 페이지를 하나씩 여는 화면에는 이 확인이 있었지만 목록에서 여러 건을 한 번에 처리하는 경로와 첨부 관리 경로에는 없어, "본인이 만든 페이지만" 으로 범위를 좁혀 위임받은 관리자가 담당 밖 페이지까지 발행 상태를 바꾸거나 첨부를 지울 수 있었습니다. 이제 대상마다 같은 기준으로 확인하며, 범위 밖 대상이 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
### Added
- 페이지 작성 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 파일을 지운 뒤 남는 빈 폴더까지 함께 정리하므로 저장소에 흔적이 남지 않습니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다.
### Changed
- 첨부 개수 상한 초과 안내 문구를 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
+23
View File
@@ -6,6 +6,7 @@ use App\Extension\AbstractModule;
use Illuminate\Database\Seeder;
use Modules\Sirsoft\Page\Database\Seeders\PageSeeder;
use Modules\Sirsoft\Page\Listeners\ActivityLogDescriptionResolver;
use Modules\Sirsoft\Page\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Page\Listeners\PageActivityLogListener;
use Modules\Sirsoft\Page\Listeners\SearchPagesListener;
use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener;
@@ -132,6 +133,28 @@ class Module extends AbstractModule
PageActivityLogListener::class,
ActivityLogDescriptionResolver::class,
SeoPageCacheListener::class,
Ckeditor5ReferenceSourcesListener::class,
];
}
/**
* 모듈 스케줄 목록 반환
*
* 페이지 작성 폼에서 올린 뒤 저장되지 않은 임시 첨부를 매일 정리합니다.
* `temp_key` 가 남아 있으면 끝내 연결되지 않은 폼 세션 부산물이라 오탐 여지가 없어
* 별도 운영자 토글 없이 상시 동작합니다 (보존기간은 커맨드 옵션).
*
* @return array<int, array<string, mixed>> 스케줄 정의 목록
*/
public function getSchedules(): array
{
return [
[
'command' => 'sirsoft-page:prune-temp-attachments',
'schedule' => 'daily',
'description' => '미연결 임시 페이지 첨부 자동 삭제',
'enabled_config' => null,
],
];
}
@@ -0,0 +1,92 @@
<?php
namespace Modules\Sirsoft\Page\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Page\Services\PageAttachmentService;
/**
* 미연결 임시 페이지 첨부 정리 커맨드
*
* 페이지 작성 폼에서 업로드만 하고 저장 없이 이탈해 남은 임시 첨부(파일 + 기록)를 정리합니다.
* 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 "끝내 연결되지 않았다" 는
* 뜻이고, 그래서 별도 운영자 토글 없이 상시 동작합니다 (운영 데이터가 아닌 폼 세션 부산물).
*
* @example php artisan sirsoft-page:prune-temp-attachments --dry-run
* @example php artisan sirsoft-page:prune-temp-attachments --days=1
*/
class PruneTempAttachmentsCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'sirsoft-page:prune-temp-attachments
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--days=2 : 임시 첨부 보존기간(일)}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '페이지에 연결되지 않은 채 방치된 임시 첨부파일을 정리합니다.';
/**
* @param PageAttachmentService $attachmentService 페이지 첨부 서비스
*/
public function __construct(
protected PageAttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
$days = (int) $this->option('days');
// 보존기간 0 이하 = 방금 올린 임시 첨부까지 지우게 되므로 차단한다.
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->pruneTempUploads($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('PruneTempAttachmentsCommand: 임시 페이지 첨부 정리 완료', [
'days' => $days,
'limit' => $limit,
'scanned' => $result['scanned'],
'deleted' => $result['deleted'],
'failed' => $result['failed'],
]);
return Command::SUCCESS;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Sirsoft\Page\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 페이지 본문을 CKEditor5 이미지 참조 스캔 대상으로 등록하는 리스너
*
* `sirsoft-ckeditor5.image.filter_reference_sources` 필터 훅에 페이지 본문과 버전
* 스냅샷 컬럼을 덧붙입니다. 버전 스냅샷을 포함하는 이유는 과거 버전으로 되돌릴 때
* 그 시점 이미지가 살아 있어야 하기 때문입니다.
*
* 테이블명 문자열만 덧붙이며 DB 에 접근하지 않습니다.
*/
class Ckeditor5ReferenceSourcesListener implements HookListenerInterface
{
/**
* 구독할 훅 목록 반환
*
* @return array<string, mixed>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ckeditor5.image.filter_reference_sources' => [
'method' => 'addPageSources',
'priority' => 10,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트를 처리합니다.
*
* Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로
* 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다.
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void {}
/**
* 페이지 참조 소스를 추가합니다.
*
* @param array $sources 기존 참조 소스 목록
* @return array 페이지 소스가 추가된 목록
*/
public function addPageSources(array $sources): array
{
$sources[] = ['table' => 'pages', 'columns' => ['content']];
$sources[] = ['table' => 'page_versions', 'columns' => ['content']];
return $sources;
}
}
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Page\Providers;
use App\Extension\BaseModuleServiceProvider;
use App\Seo\SitemapGenerator;
use Modules\Sirsoft\Page\Console\Commands\PlaywrightSeedPage;
use Modules\Sirsoft\Page\Console\Commands\PruneTempAttachmentsCommand;
use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface;
@@ -57,6 +58,7 @@ class PageServiceProvider extends BaseModuleServiceProvider
if ($this->app->runningInConsole()) {
$this->commands([
PlaywrightSeedPage::class,
PruneTempAttachmentsCommand::class,
]);
}
@@ -3,6 +3,8 @@
namespace Modules\Sirsoft\Page\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Carbon;
use Modules\Sirsoft\Page\Models\PageAttachment;
/**
@@ -65,7 +67,7 @@ interface PageAttachmentRepositoryInterface
* @param int $id 첨부파일 ID
* @return PageAttachment 첨부파일 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): PageAttachment;
@@ -77,6 +79,18 @@ interface PageAttachmentRepositoryInterface
*/
public function delete(PageAttachment $attachment): bool;
/**
* 페이지에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다.
*
* 페이지 작성 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채
* `page_id` 가 비어 있는 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @return Collection<int, PageAttachment> 임시 첨부 목록 (created_at 오름차순)
*/
public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection;
/**
* 첨부파일을 수정합니다.
*
@@ -3,6 +3,8 @@
namespace Modules\Sirsoft\Page\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Page\Models\PageAttachment;
use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface;
@@ -97,7 +99,7 @@ class PageAttachmentRepository implements PageAttachmentRepositoryInterface
* @param int $id 첨부파일 ID
* @return PageAttachment 첨부파일 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): PageAttachment
{
@@ -115,6 +117,21 @@ class PageAttachmentRepository implements PageAttachmentRepositoryInterface
return (bool) $attachment->delete();
}
/**
* {@inheritDoc}
*/
public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection
{
return PageAttachment::query()
->whereNotNull('temp_key')
->whereNull('page_id')
->where('created_at', '<', $threshold)
->orderBy('created_at')
->orderBy('id')
->limit($limit)
->get(['id', 'temp_key', 'disk', 'path', 'created_at']);
}
/**
* 첨부파일을 수정합니다.
*
@@ -9,7 +9,9 @@ use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\Sirsoft\Page\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Page\Models\PageAttachment;
@@ -208,6 +210,114 @@ class PageAttachmentService
return $result;
}
/**
* 페이지에 연결되지 않은 채 방치된 임시 첨부를 정리합니다.
*
* 페이지 작성 폼에서 첨부만 올리고 저장하지 않고 이탈하면 `temp_key` 만 남은 행과
* 그 파일이 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은
* "끝내 연결되지 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다.
*
* 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다.
*
* @param int $days 보존기간(일)
* @param int $limit 한 회차에 처리할 최대 건수
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array
{
$threshold = Carbon::now()->subDays($days);
$attachments = $this->attachmentRepository->findStaleTempAttachments($threshold, $limit);
$result = ['scanned' => $attachments->count(), 'deleted' => 0, 'failed' => 0];
if ($dryRun) {
return $result;
}
foreach ($attachments as $attachment) {
$storage = $this->storageForRow($attachment->disk);
if ($storage->exists('attachments', $attachment->path) && ! $storage->delete('attachments', $attachment->path)) {
Log::warning('임시 페이지 첨부 파일 삭제 실패 — 기록 보존', [
'attachment_id' => $attachment->id,
'disk' => $attachment->disk,
'path' => $attachment->path,
]);
$result['failed']++;
continue;
}
$this->attachmentRepository->delete($attachment);
$result['deleted']++;
}
$this->removeEmptyTempDirectories($attachments);
return $result;
}
/**
* 파일을 모두 지운 temp_key 디렉토리를 정리합니다.
*
* 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
* 저장소에는 흔적이 계속 늘어납니다.
*
* 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 첨부가 limit 에 걸려 이번 회차에서
* 빠졌거나 파일 삭제에 실패한 경우 등) 삭제하지 않습니다.
*
* @param Collection $attachments 이번 회차에 처리한 첨부 목록
*/
private function removeEmptyTempDirectories(Collection $attachments): void
{
$directories = [];
foreach ($attachments as $attachment) {
$directory = dirname((string) $attachment->path);
if ($directory === '' || $directory === '.') {
continue;
}
$directories[$attachment->disk.'|'.$directory] = [$attachment->disk, $directory];
}
foreach ($directories as [$disk, $directory]) {
$storage = $this->storageForRow($disk);
if ($storage->files('attachments', $directory) !== []) {
continue;
}
$storage->deleteDirectory('attachments', $directory);
}
}
/**
* 첨부 행에 기록된 disk 기준 스토리지를 반환합니다.
*
* 디스크를 전환한 뒤에도 전환 이전 행의 파일을 그 행의 실제 저장 위치에서 지우기 위한
* 해석입니다. 미등록 disk(그 디스크를 제공하던 확장이 비활성화된 경우)는 주입 스토리지로
* 폴백합니다 — withDisk 로 미등록 disk 인스턴스를 만들면 이후 호출이 예외가 됩니다.
*
* @param string|null $disk 행의 disk 컬럼 값
* @return StorageInterface 행 disk 의 스토리지
*/
private function storageForRow(?string $disk): StorageInterface
{
if ($disk === null || $disk === '' || $disk === $this->storage->getDisk()) {
return $this->storage;
}
if (config("filesystems.disks.{$disk}") === null) {
return $this->storage;
}
return $this->storage->withDisk($disk);
}
/**
* 첨부파일 순서를 변경합니다.
*
@@ -0,0 +1,191 @@
<?php
namespace Modules\Sirsoft\Page\Tests\Feature\Console;
use App\Extension\Storage\ModuleStorageDriver;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageAttachment;
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 미연결 임시 페이지 첨부 정리 커맨드 테스트
*
* `temp_key` 가 남고 `page_id` 가 비어 있는 행만 대상이어야 합니다 — 연결된 첨부가
* 섞이면 공개 중인 페이지의 첨부가 사라집니다.
*/
class PruneTempAttachmentsCommandTest extends ModuleTestCase
{
/**
* 임시 첨부 파일과 기록을 함께 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @return PageAttachment 생성된 첨부
*/
private function makeAttachment(array $attributes = []): PageAttachment
{
$filename = uniqid('temp-page-').'.png';
$path = $attributes['path'] ?? "temp/tempkey/{$filename}";
(new ModuleStorageDriver('sirsoft-page', 'modules'))->put('attachments', $path, 'bytes');
return PageAttachment::create(array_merge([
'page_id' => null,
'temp_key' => 'tempkey',
'original_filename' => $filename,
'stored_filename' => $filename,
'disk' => 'modules',
'path' => $path,
'mime_type' => 'image/png',
'size' => 5,
'collection' => 'attachments',
'order' => 1,
], $attributes));
}
/**
* @scenario age=past_retention, attachment_state=temp_unlinked
*
* @effects page_temp_prune_deletes_file_and_record
*/
#[Test]
public function stale_temp_attachments_lose_both_file_and_record(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful();
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-page', 'modules'))->exists('attachments', $attachment->path)
);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=within_retention, attachment_state=temp_unlinked
*
* @effects page_temp_prune_keeps_within_retention
*/
#[Test]
public function temp_attachment_within_retention_is_kept(): void
{
$attachment = $this->makeAttachment();
$this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful();
$this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=past_retention, attachment_state=linked_to_page
*
* @effects page_temp_prune_skips_linked
*/
#[Test]
public function attachment_linked_to_a_page_is_never_pruned(): void
{
$page = Page::create([
'slug' => 'prune-temp-attachment-test-'.uniqid(),
'title' => ['ko' => '연결 첨부 테스트', 'en' => 'Linked Attachment Test'],
'content' => ['ko' => '', 'en' => ''],
'published' => true,
]);
$attachment = $this->makeAttachment([
'page_id' => $page->id,
'temp_key' => null,
'path' => '2026/08/14/'.uniqid('linked-').'.png',
]);
$attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful();
$this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]);
}
/**
* @effects page_temp_prune_dry_run
*/
#[Test]
public function dry_run_reports_targets_without_deleting(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-page:prune-temp-attachments --dry-run')
->expectsOutputToContain('[DRY RUN]')
->assertSuccessful();
$this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]);
}
/**
* @effects page_temp_prune_days_guard
*/
#[Test]
public function retention_below_one_day_performs_no_cleanup(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-page:prune-temp-attachments --days=0')->assertSuccessful();
$this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]);
}
/**
* 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀)
*
* 파일만 지우고 디렉토리를 남기면 페이지 작성 폼 세션마다 빈 디렉토리가 쌓여, 정리를
* 돌려도 저장소 흔적은 계속 늘어난다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked
*
* @effects page_temp_prune_removes_empty_directory
*/
#[Test]
public function emptied_temp_directory_is_removed(): void
{
$attachment = $this->makeAttachment(['path' => 'temp/tempkey-empty-dir/'.uniqid('t-').'.png']);
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful();
$storage = new ModuleStorageDriver('sirsoft-page', 'modules');
$this->assertSame(
[],
$storage->files('attachments', 'temp/tempkey-empty-dir'),
'디렉토리 안의 파일은 모두 삭제되어야 한다.'
);
$this->assertDirectoryDoesNotExist(
rtrim($storage->getBasePath('attachments'), '/\\').'/temp/tempkey-empty-dir',
'비워진 temp 디렉토리는 남기지 않는다.'
);
}
/**
* 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked
*
* @effects page_temp_prune_keeps_directory_with_remaining_files
*/
#[Test]
public function temp_directory_with_remaining_files_is_kept(): void
{
$directory = 'temp/tempkey-partial';
$processed = $this->makeAttachment(['path' => $directory.'/'.uniqid('done-').'.png']);
$processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
// 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태
$storage = new ModuleStorageDriver('sirsoft-page', 'modules');
$storage->put('attachments', $directory.'/keep.png', 'bytes');
$this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful();
$this->assertTrue($storage->exists('attachments', $directory.'/keep.png'));
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Sirsoft\Page\Tests\Unit\Listeners;
use Modules\Sirsoft\Page\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 페이지 참조 소스 등록 리스너 테스트
*
* 이 리스너가 빠지면 페이지 본문에만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이
* 되므로, 구독 형식(filter)과 append 계약을 회귀로 고정합니다.
*/
class Ckeditor5ReferenceSourcesListenerTest extends ModuleTestCase
{
/**
* @effects reference_source_listener_subscribes_as_filter
*/
#[Test]
public function it_subscribes_to_the_reference_source_filter_hook(): void
{
$hooks = Ckeditor5ReferenceSourcesListener::getSubscribedHooks();
$this->assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks);
$this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']);
$this->assertSame('addPageSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']);
}
/**
* 버전 스냅샷도 포함해야 과거 버전으로 되돌릴 때 그 시점 이미지가 남아 있다.
*
* @effects reference_source_listener_appends_without_dropping
*/
#[Test]
public function it_appends_page_and_version_sources_while_preserving_existing_ones(): void
{
$existing = [['table' => 'mail_templates', 'columns' => ['body']]];
$result = (new Ckeditor5ReferenceSourcesListener)->addPageSources($existing);
$this->assertContains($existing[0], $result);
$this->assertContains(['table' => 'pages', 'columns' => ['content']], $result);
$this->assertContains(['table' => 'page_versions', 'columns' => ['content']], $result);
}
/**
* @effects reference_source_listener_schema_contract
*/
#[Test]
public function appended_sources_follow_the_declared_schema(): void
{
foreach ((new Ckeditor5ReferenceSourcesListener)->addPageSources([]) as $source) {
$this->assertArrayHasKey('table', $source);
$this->assertArrayHasKey('columns', $source);
$this->assertIsString($source['table']);
$this->assertIsArray($source['columns']);
$this->assertNotEmpty($source['columns']);
}
}
}

Some files were not shown because too many files have changed in this diff Show More