공개 (@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종
483 lines
15 KiB
PHP
483 lines
15 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Modules\Sirsoft\Board\Models\Attachment;
|
|
use Modules\Sirsoft\Board\Models\Board;
|
|
use Modules\Sirsoft\Board\Models\Post;
|
|
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
|
|
|
|
/**
|
|
* 게시판 첨부파일 Repository 구현체
|
|
*/
|
|
class AttachmentRepository implements AttachmentRepositoryInterface
|
|
{
|
|
/**
|
|
* ID로 첨부파일 조회
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $id 첨부파일 ID
|
|
* @return Attachment|null 첨부파일 또는 null
|
|
*/
|
|
public function findById(string $slug, int $id): ?Attachment
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('id', $id)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* 해시로 첨부파일 조회
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string $hash 첨부파일 해시
|
|
* @return Attachment|null 첨부파일 또는 null
|
|
*/
|
|
public function findByHash(string $slug, string $hash): ?Attachment
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('hash', $hash)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* 첨부파일이 속한 게시글이 삭제(soft delete) 상태인지 확인합니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @return bool 소속 게시글이 삭제 상태인지 여부
|
|
*/
|
|
public function isPostDeleted(string $slug, int $postId): bool
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
if (! $board) {
|
|
return false;
|
|
}
|
|
|
|
$post = Post::withTrashed()
|
|
->where('board_id', $board->id)
|
|
->where('id', $postId)
|
|
->first(['id', 'status', 'deleted_at']);
|
|
|
|
if (! $post) {
|
|
return false;
|
|
}
|
|
|
|
return $post->deleted_at !== null || $post->status === PostStatus::Deleted;
|
|
}
|
|
|
|
/**
|
|
* 첨부파일이 속한 게시글을 게시판 스코프로 조회합니다(비밀 게이팅용).
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @return Post|null 게시글 모델 또는 null
|
|
*/
|
|
public function findPostForGate(string $slug, int $postId): ?Post
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
if (! $board) {
|
|
return null;
|
|
}
|
|
|
|
return Post::withTrashed()
|
|
->where('board_id', $board->id)
|
|
->where('id', $postId)
|
|
->first(['id', 'board_id', 'user_id', 'is_secret', 'status', 'deleted_at']);
|
|
}
|
|
|
|
/**
|
|
* 여러 ID로 첨부파일 조회 (order 정렬)
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param array<int> $ids 첨부파일 ID 배열
|
|
* @return Collection<int, Attachment>
|
|
*/
|
|
public function findByIds(string $slug, array $ids): Collection
|
|
{
|
|
if (empty($ids)) {
|
|
// `collect()` 는 Support 라 선언과 어긋난다 — 빈 결과도 모델 컬렉션이어야 한다.
|
|
return new Collection;
|
|
}
|
|
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->whereIn('id', $ids)
|
|
->orderBy('order')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 게시글별 첨부파일 조회
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @param string|null $collection 컬렉션 필터 (null이면 전체)
|
|
* @return Collection<int, Attachment>
|
|
*/
|
|
public function getByPost(string $slug, int $postId, ?string $collection = null): Collection
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
$query = Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('post_id', $postId)
|
|
->orderBy('order');
|
|
|
|
if ($collection !== null) {
|
|
$query->where('collection', $collection);
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 생성
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param array<string, mixed> $data 생성 데이터
|
|
* @return Attachment 생성된 첨부파일
|
|
*/
|
|
public function create(string $slug, array $data): Attachment
|
|
{
|
|
return Attachment::create($data);
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 업데이트
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $id 첨부파일 ID
|
|
* @param array<string, mixed> $data 업데이트 데이터
|
|
* @return Attachment 업데이트된 첨부파일
|
|
*/
|
|
public function update(string $slug, int $id, array $data): Attachment
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
$attachment = Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('id', $id)
|
|
->firstOrFail();
|
|
|
|
$attachment->update($data);
|
|
|
|
return $attachment->fresh();
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 삭제
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $id 첨부파일 ID
|
|
* @return bool 삭제 성공 여부
|
|
*/
|
|
public function delete(string $slug, int $id): bool
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
$attachment = Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('id', $id)
|
|
->firstOrFail();
|
|
|
|
return $attachment->delete();
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 순서 재정렬
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param array<int, int> $orders 첨부파일 ID => order 매핑
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function reorder(string $slug, array $orders): bool
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
DB::beginTransaction();
|
|
try {
|
|
foreach ($orders as $id => $order) {
|
|
Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('id', $id)
|
|
->update(['order' => $order]);
|
|
}
|
|
DB::commit();
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 컬렉션의 최대 order 조회
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @param string $collection 컬렉션명
|
|
* @return int 최대 order 값
|
|
*/
|
|
public function getMaxOrder(string $slug, int $postId, string $collection): int
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
$maxOrder = Attachment::query()
|
|
->where('board_id', $board?->id)
|
|
->where('post_id', $postId)
|
|
->where('collection', $collection)
|
|
->max('order');
|
|
|
|
return $maxOrder ?? 0;
|
|
}
|
|
|
|
/**
|
|
* 임시 업로드의 최대 order 조회
|
|
*
|
|
* 임시 업로드 레코드는 board_id=0으로 저장됩니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string|null $tempKey 임시 업로드 키
|
|
* @param string $collection 컬렉션명
|
|
* @return int 최대 order 값
|
|
*/
|
|
public function getMaxOrderByTempKey(string $slug, ?string $tempKey, string $collection): int
|
|
{
|
|
if (! $tempKey) {
|
|
return 0;
|
|
}
|
|
|
|
// 임시 업로드 레코드: board_id=0
|
|
$maxOrder = Attachment::query()
|
|
->where('board_id', 0)
|
|
->whereNull('post_id')
|
|
->where('temp_key', $tempKey)
|
|
->where('collection', $collection)
|
|
->max('order');
|
|
|
|
return $maxOrder ?? 0;
|
|
}
|
|
|
|
/**
|
|
* 임시 업로드 키로 첨부파일 조회
|
|
*
|
|
* 임시 업로드 레코드는 board_id=0으로 저장됩니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string $tempKey 임시 업로드 키
|
|
* @param string|null $collection 컬렉션 필터 (null이면 전체)
|
|
* @return Collection<int, Attachment>
|
|
*/
|
|
public function getByTempKey(string $slug, string $tempKey, ?string $collection = null): Collection
|
|
{
|
|
// 임시 업로드 레코드: board_id=0
|
|
$query = Attachment::query()
|
|
->where('board_id', 0)
|
|
->whereNull('post_id')
|
|
->where('temp_key', $tempKey)
|
|
->orderBy('order');
|
|
|
|
if ($collection !== null) {
|
|
$query->where('collection', $collection);
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
/**
|
|
* 임시 첨부파일을 게시글에 연결
|
|
*
|
|
* 임시 레코드(board_id=0)를 실제 board_id로 이동합니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param string $tempKey 임시 업로드 키
|
|
* @param int $postId 게시글 ID
|
|
* @return int 연결된 첨부파일 수
|
|
*/
|
|
public function linkTempAttachments(string $slug, string $tempKey, int $postId): int
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
// 임시 파일(board_id=0)을 실제 board_id + post_id로 업데이트
|
|
return Attachment::query()
|
|
->where('board_id', 0)
|
|
->whereNull('post_id')
|
|
->where('temp_key', $tempKey)
|
|
->update([
|
|
'board_id' => $board?->id ?? 0,
|
|
'post_id' => $postId,
|
|
'temp_key' => null,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 ID 배열로 게시글에 연결
|
|
*
|
|
* 임시 업로드(board_id=0)된 첨부파일을 실제 board_id로 이동하고 게시글에 연결합니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param array<int> $ids 첨부파일 ID 배열
|
|
* @param int $postId 게시글 ID
|
|
* @return int 연결된 첨부파일 수
|
|
*/
|
|
public function linkAttachmentsByIds(string $slug, array $ids, int $postId): int
|
|
{
|
|
if (empty($ids)) {
|
|
return 0;
|
|
}
|
|
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
// 임시 업로드(board_id=0)된 첨부파일을 실제 board_id로 이동하며 게시글에 연결
|
|
return Attachment::query()
|
|
->where('board_id', 0)
|
|
->whereIn('id', $ids)
|
|
->whereNull('post_id')
|
|
->update([
|
|
'board_id' => $board?->id ?? 0,
|
|
'post_id' => $postId,
|
|
'temp_key' => null,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 게시판 ID 기준으로 첨부파일을 일괄 소프트 삭제합니다.
|
|
*
|
|
* @param int $boardId 게시판 ID
|
|
* @return int 삭제된 첨부파일 수
|
|
*/
|
|
public function softDeleteByBoardId(int $boardId): int
|
|
{
|
|
return Attachment::where('board_id', $boardId)->delete();
|
|
}
|
|
|
|
/**
|
|
* 게시글 ID 기준으로 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다.
|
|
*
|
|
* 게시글 삭제 연쇄로 지워졌음을 trigger_type='cascade' 로 마킹합니다.
|
|
* 이미 삭제된(trashed) 첨부는 Eloquent 기본 스코프가 제외하므로,
|
|
* 사용자가 먼저 지운 첨부(trigger_type='user')는 영향을 받지 않습니다.
|
|
* 물리 파일은 보존하며 deleted_at 마킹만 수행합니다. (복원 가능성 유지)
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @return int 삭제된 첨부 수
|
|
*/
|
|
public function softDeleteByPostId(string $slug, int $postId): int
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::where('board_id', $board?->id)
|
|
->where('post_id', $postId)
|
|
->update([
|
|
'trigger_type' => TriggerType::Cascade->value,
|
|
'deleted_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 게시글 ID 기준으로 cascade 로 지워진 첨부만 복원합니다.
|
|
*
|
|
* 게시글 복원 시, 게시글 삭제 연쇄로 지워진(trigger_type='cascade') 첨부만
|
|
* 되살립니다. 사용자가 직접 지운(trigger_type='user') 첨부는 trashed 로 유지됩니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param int $postId 게시글 ID
|
|
* @return int 복원된 첨부 수
|
|
*/
|
|
public function restoreCascadedByPostId(string $slug, int $postId): int
|
|
{
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::onlyTrashed()
|
|
->where('board_id', $board?->id)
|
|
->where('post_id', $postId)
|
|
->where('trigger_type', TriggerType::Cascade->value)
|
|
->update([
|
|
'deleted_at' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다.
|
|
*
|
|
* 게시판 영구 삭제(deleteBoard) 시 사용합니다. 소프트 삭제와 달리
|
|
* deleted_at 마킹이 아니라 레코드를 물리적으로 제거합니다.
|
|
* (물리 파일은 BoardService::deleteAttachmentFiles 가 별도로 삭제)
|
|
*
|
|
* @param int $boardId 게시판 ID
|
|
* @return int 삭제된 첨부파일 수
|
|
*/
|
|
public function forceDeleteByBoardId(int $boardId): int
|
|
{
|
|
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();
|
|
}
|
|
}
|