Files
Gnuboard7/app/Repositories/AttachmentRepository.php
T
HeuJung cfc2caba1b 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종
2026-08-17 02:44:54 +09:00

327 lines
9.8 KiB
PHP

<?php
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;
/**
* 첨부파일 Repository 구현체
*/
class AttachmentRepository implements AttachmentRepositoryInterface
{
/**
* ID로 첨부파일 조회
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findById(int $id): ?Attachment
{
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 정렬)
*
* @param array<int> $ids 첨부파일 ID 배열
* @return Collection<int, Attachment>
*/
public function findByIds(array $ids): Collection
{
if (empty($ids)) {
// `collect()` 는 Support 라 선언과 어긋난다 — 빈 결과도 모델 컬렉션이어야 한다.
return new Collection;
}
return Attachment::whereIn('id', $ids)
->ordered()
->get();
}
/**
* 해시로 첨부파일 조회
*
* @param string $hash 첨부파일 해시
* @return Attachment|null 첨부파일 또는 null
*/
public function findByHash(string $hash): ?Attachment
{
return Attachment::where('hash', $hash)->first();
}
/**
* 첨부 대상별 첨부파일 조회
*
* @param string $type attachmentable_type
* @param int $id attachmentable_id
* @param string|null $collection 컬렉션 필터 (null이면 전체)
* @return Collection<int, Attachment>
*/
public function getByAttachmentable(string $type, int $id, ?string $collection = null): Collection
{
$query = Attachment::where('attachmentable_type', $type)
->where('attachmentable_id', $id)
->ordered();
if ($collection !== null) {
$query->collection($collection);
}
return $query->get();
}
/**
* 첨부파일 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return Attachment 생성된 첨부파일
*/
public function create(array $data): Attachment
{
return Attachment::create($data);
}
/**
* 첨부파일 업데이트
*
* @param int $id 첨부파일 ID
* @param array<string, mixed> $data 업데이트 데이터
* @return Attachment 업데이트된 첨부파일
*/
public function update(int $id, array $data): Attachment
{
$attachment = Attachment::findOrFail($id);
$attachment->update($data);
return $attachment->fresh();
}
/**
* 첨부파일 삭제 (soft delete)
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
$attachment = Attachment::find($id);
if (! $attachment) {
return false;
}
return $attachment->delete();
}
/**
* 첨부파일 영구 삭제
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function forceDelete(int $id): bool
{
$attachment = Attachment::withTrashed()->find($id);
if (! $attachment) {
return false;
}
return $attachment->forceDelete();
}
/**
* 순서 변경
*
* @param array<int, array{id: int, order: int}> $orderData 순서 데이터
*/
public function reorder(array $orderData): void
{
DB::transaction(function () use ($orderData) {
foreach ($orderData as $item) {
Attachment::where('id', $item['id'])
->update(['order' => $item['order']]);
}
});
}
/**
* 특정 소스 식별자의 첨부파일 목록 조회
*
* @param string $identifier 소스 식별자
* @return Collection 첨부파일 컬렉션
*/
public function getBySourceIdentifier(string $identifier): Collection
{
return Attachment::where('source_identifier', $identifier)->get();
}
/**
* 특정 소스 식별자의 첨부파일 일괄 삭제
*
* @param string $identifier 소스 식별자
* @return int 삭제된 개수
*/
public function deleteBySourceIdentifier(string $identifier): int
{
return Attachment::where('source_identifier', $identifier)->delete();
}
/**
* 컬렉션 내 최대 order 값 조회
*
* @param string $type attachmentable_type
* @param int $id attachmentable_id
* @param string $collection 컬렉션명
* @return int 최대 order 값
*/
public function getMaxOrder(string $type, int $id, string $collection): int
{
return Attachment::where('attachmentable_type', $type)
->where('attachmentable_id', $id)
->where('collection', $collection)
->max('order') ?? 0;
}
/**
* 컬렉션명으로만 최대 order 값 조회 (attachmentable 없는 경우)
*
* @param string $collection 컬렉션명
* @return int 최대 order 값
*/
public function getMaxOrderByCollection(string $collection): int
{
return Attachment::where('collection', $collection)
->whereNull('attachmentable_type')
->whereNull('attachmentable_id')
->max('order') ?? 0;
}
/**
* 컬렉션명으로 첨부파일 조회 (attachmentable 없는 경우)
*
* @param string $collection 컬렉션명
* @return Collection<int, Attachment>
*/
public function getByCollection(string $collection): Collection
{
return Attachment::where('collection', $collection)
->whereNull('attachmentable_type')
->whereNull('attachmentable_id')
->ordered()
->get();
}
/**
* 미연결 첨부파일을 특정 엔티티에 연결합니다.
* 기존 연결된 파일들의 order 뒤에 순차적으로 배치됩니다.
*
* @param string $collection 컬렉션명
* @param string $attachmentableType 연결할 모델 클래스명
* @param int $attachmentableId 연결할 모델 ID
* @return int 연결된 첨부파일 수
*/
public function linkUnattachedFiles(string $collection, string $attachmentableType, int $attachmentableId): int
{
// 기존 연결된 파일들의 최대 order 조회
$maxOrder = $this->getMaxOrder($attachmentableType, $attachmentableId, $collection);
// 미연결 파일들을 order 순으로 조회
$unattachedFiles = Attachment::where('collection', $collection)
->whereNull('attachmentable_type')
->whereNull('attachmentable_id')
->orderBy('order')
->orderBy('id')
->get();
if ($unattachedFiles->isEmpty()) {
return 0;
}
// 각 파일에 대해 연결 및 order 재설정
$count = 0;
foreach ($unattachedFiles as $file) {
$maxOrder++;
$file->update([
'attachmentable_type' => $attachmentableType,
'attachmentable_id' => $attachmentableId,
'order' => $maxOrder,
]);
$count++;
}
return $count;
}
/**
* 특정 엔티티의 첨부파일 순서를 재정렬합니다.
* 삭제 등으로 중간에 빈 순서가 생긴 경우 1부터 연속된 순서로 재정렬합니다.
*
* @param string $attachmentableType 첨부 대상 타입
* @param int $attachmentableId 첨부 대상 ID
* @param string $collection 컬렉션명
*/
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void
{
$attachments = Attachment::where('attachmentable_type', $attachmentableType)
->where('attachmentable_id', $attachmentableId)
->where('collection', $collection)
->orderBy('order')
->orderBy('id')
->get();
DB::transaction(function () use ($attachments) {
$order = 1;
foreach ($attachments as $attachment) {
if ($attachment->order !== $order) {
$attachment->update(['order' => $order]);
}
$order++;
}
});
}
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* @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']);
}
}