공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.
- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
(전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
(ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
537 lines
17 KiB
PHP
537 lines
17 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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다.
|
|
*
|
|
* 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의
|
|
* 첨부 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다.
|
|
* 물리 파일은 보존하며 deleted_at 마킹만 수행합니다. (복원 가능성 유지)
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param array<int> $postIds 게시글 ID 배열
|
|
* @return int 삭제된 첨부 수
|
|
*/
|
|
public function softDeleteByPostIds(string $slug, array $postIds): int
|
|
{
|
|
if ($postIds === []) {
|
|
return 0;
|
|
}
|
|
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::where('board_id', $board?->id)
|
|
->whereIn('post_id', $postIds)
|
|
->update([
|
|
'trigger_type' => TriggerType::Cascade->value,
|
|
'deleted_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다.
|
|
*
|
|
* 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의
|
|
* 첨부 복원용입니다. 사용자가 직접 지운 첨부(trigger_type='user')는 유지됩니다.
|
|
*
|
|
* @param string $slug 게시판 슬러그
|
|
* @param array<int> $postIds 게시글 ID 배열
|
|
* @return int 복원된 첨부 수
|
|
*/
|
|
public function restoreCascadedByPostIds(string $slug, array $postIds): int
|
|
{
|
|
if ($postIds === []) {
|
|
return 0;
|
|
}
|
|
|
|
$board = Board::where('slug', $slug)->first();
|
|
|
|
return Attachment::onlyTrashed()
|
|
->where('board_id', $board?->id)
|
|
->whereIn('post_id', $postIds)
|
|
->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();
|
|
}
|
|
}
|