ofBoard($boardId) ->with(['author', 'board']) ->withCount(['files', 'comments']) ->published(); // 슈퍼관리자: 삭제된 게시물 포함 if ($includeTrashed) { $query->withTrashed(); } // 검색 if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('content', 'like', "%{$search}%"); }); } // 공지사항 필터 if (isset($filters['is_notice'])) { $query->where('is_notice', $filters['is_notice']); } // 삭제 상태 필터 if (isset($filters['trashed'])) { if ($filters['trashed'] === 'only') { $query->onlyTrashed(); } elseif ($filters['trashed'] === 'with') { $query->withTrashed(); } } // 정렬: 공지사항 먼저, 그 다음 최신순 $query->orderByDesc('is_notice') ->orderByDesc('created_at'); return $query->paginate($perPage); } /** * 공지사항 목록 (상단 고정용) */ public function getNotices(int $boardId, int $limit = 5): Collection { return Post::query() ->ofBoard($boardId) ->published() ->notices() ->with('author') ->orderByDesc('created_at') ->limit($limit) ->get(); } /** * 게시글 상세 조회 */ public function getPostById(int $id, bool $withTrashed = false): ?Post { $query = Post::query() ->with(['board.fields', 'author', 'customFieldValues.field']); if ($withTrashed) { $query->withTrashed(); } return $query->find($id); } /** * 게시글 생성 */ public function createPost(Board $board, array $data, array $customFields = []): Post { return DB::transaction(function () use ($board, $data, $customFields) { // 기본 데이터 설정 $data['board_id'] = $board->id; // 시스템 게시판(tenant_id=null)인 경우 사용자의 현재 테넌트 사용 $data['tenant_id'] = $board->tenant_id ?? auth()->user()->currentTenant()?->id; $data['user_id'] = auth()->id(); $data['created_by'] = auth()->id(); $data['editor_type'] = $board->editor_type; $data['ip_address'] = request()->ip(); $post = Post::create($data); // 커스텀 필드 저장 $this->saveCustomFields($post, $board, $customFields); return $post->load(['board', 'author', 'customFieldValues.field']); }); } /** * 게시글 수정 */ public function updatePost(Post $post, array $data, array $customFields = []): Post { return DB::transaction(function () use ($post, $data, $customFields) { $data['updated_by'] = auth()->id(); $post->update($data); // 커스텀 필드 업데이트 $this->saveCustomFields($post, $post->board, $customFields); return $post->fresh(['board', 'author', 'customFieldValues.field']); }); } /** * 게시글 삭제 (Soft Delete) */ public function deletePost(Post $post): bool { $post->deleted_by = auth()->id(); $post->save(); return $post->delete(); } /** * 게시글 영구 삭제 */ public function forceDeletePost(Post $post): bool { // 첨부파일 영구 삭제 $this->forceDeleteAllFiles($post); // 커스텀 필드 값 삭제 $post->customFieldValues()->delete(); return $post->forceDelete(); } /** * 게시글 복원 */ public function restorePost(Post $post): bool { $post->deleted_by = null; return $post->restore(); } /** * 조회수 증가 */ public function incrementViews(Post $post): void { $post->incrementViews(); } /** * 커스텀 필드 저장 */ private function saveCustomFields(Post $post, Board $board, array $customFields): void { // 기존 값 삭제 후 새로 저장 (upsert 대신 간단한 방식) $post->customFieldValues()->delete(); $boardFields = $board->fields; foreach ($boardFields as $field) { $value = $customFields[$field->field_key] ?? null; if ($value !== null && $value !== '') { PostCustomFieldValue::create([ 'post_id' => $post->id, 'field_id' => $field->id, 'value' => is_array($value) ? json_encode($value) : $value, 'created_by' => auth()->id(), ]); } } } /** * 이전/다음 글 조회 */ public function getAdjacentPosts(Post $post): array { $prev = Post::query() ->ofBoard($post->board_id) ->published() ->where('id', '<', $post->id) ->orderByDesc('id') ->first(['id', 'title']); $next = Post::query() ->ofBoard($post->board_id) ->published() ->where('id', '>', $post->id) ->orderBy('id') ->first(['id', 'title']); return compact('prev', 'next'); } /** * 게시판 통계 */ public function getBoardPostStats(int $boardId, bool $includeTrashed = false): array { $stats = [ 'total' => Post::ofBoard($boardId)->count(), 'published' => Post::ofBoard($boardId)->published()->count(), 'notices' => Post::ofBoard($boardId)->notices()->count(), 'today' => Post::ofBoard($boardId)->whereDate('created_at', today())->count(), ]; // 슈퍼관리자용: 삭제된 게시물 통계 if ($includeTrashed) { $stats['trashed'] = Post::ofBoard($boardId)->onlyTrashed()->count(); } return $stats; } // ========================================================================= // File Management // ========================================================================= /** * 파일 업로드 및 게시글 연결 */ public function uploadFiles(Post $post, array $files): array { $board = $post->board; $uploadedFiles = []; // 파일 첨부 허용 여부 확인 if (! $board->allow_files) { throw new \Exception('이 게시판은 파일 첨부가 허용되지 않습니다.'); } // 최대 파일 개수 확인 $currentCount = $post->files()->count(); $maxCount = $board->max_file_count; if ($currentCount + count($files) > $maxCount) { throw new \Exception("최대 {$maxCount}개의 파일만 첨부할 수 있습니다."); } $tenantId = $post->tenant_id; $year = now()->format('Y'); $month = now()->format('m'); // Path pattern: {tenant_id}/{folder_key}/{year}/{month}/{stored_name} // (tenant disk root already includes /tenants/) $basePath = "{$tenantId}/posts/{$year}/{$month}"; foreach ($files as $file) { if (! $file instanceof UploadedFile) { continue; } // 파일 크기 확인 (KB 단위로 저장됨) $fileSizeKB = $file->getSize() / 1024; if ($fileSizeKB > $board->max_file_size) { $maxSizeMB = round($board->max_file_size / 1024, 1); throw new \Exception("파일 크기는 {$maxSizeMB}MB를 초과할 수 없습니다."); } // 저장 파일명 생성 $storedName = Str::uuid().'.'.$file->getClientOriginalExtension(); $filePath = "{$basePath}/{$storedName}"; // 파일 저장 Storage::disk('tenant')->put($filePath, file_get_contents($file)); // DB 레코드 생성 (API 방식: document_id + document_type) $fileRecord = File::create([ 'tenant_id' => $tenantId, 'is_temp' => false, 'file_path' => $filePath, 'display_name' => $file->getClientOriginalName(), 'stored_name' => $storedName, 'original_name' => $file->getClientOriginalName(), 'file_name' => $file->getClientOriginalName(), 'file_size' => $file->getSize(), 'mime_type' => $file->getMimeType(), 'file_type' => $this->getFileType($file->getMimeType()), 'document_type' => 'post', 'document_id' => $post->id, 'uploaded_by' => auth()->id(), 'created_by' => auth()->id(), ]); $uploadedFiles[] = $fileRecord; } return $uploadedFiles; } /** * 게시글 파일 삭제 */ public function deleteFile(Post $post, int $fileId): bool { $file = $post->files()->find($fileId); if (! $file) { throw new \Exception('파일을 찾을 수 없습니다.'); } // 물리 파일 삭제 if ($file->existsInStorage()) { Storage::disk('tenant')->delete($file->file_path); } // 소프트 삭제 $file->deleted_by = auth()->id(); $file->save(); return $file->delete(); } /** * 게시글 파일 전체 삭제 (게시글 삭제 시) */ public function deleteAllFiles(Post $post): void { foreach ($post->files as $file) { $file->deleted_by = auth()->id(); $file->save(); $file->delete(); } } /** * 게시글 파일 영구 삭제 (게시글 영구 삭제 시) */ public function forceDeleteAllFiles(Post $post): void { foreach ($post->files()->withTrashed()->get() as $file) { $file->permanentDelete(); } } /** * 파일 타입 추출 */ private function getFileType(?string $mimeType): string { if (! $mimeType) { return 'other'; } if (str_starts_with($mimeType, 'image/')) { return 'image'; } if (str_starts_with($mimeType, 'video/')) { return 'video'; } if (str_starts_with($mimeType, 'audio/')) { return 'audio'; } if (str_contains($mimeType, 'pdf')) { return 'pdf'; } if (str_contains($mimeType, 'word') || str_contains($mimeType, 'document')) { return 'document'; } if (str_contains($mimeType, 'excel') || str_contains($mimeType, 'spreadsheet')) { return 'spreadsheet'; } if (str_contains($mimeType, 'zip') || str_contains($mimeType, 'rar') || str_contains($mimeType, 'compressed')) { return 'archive'; } return 'other'; } /** * 게시글 파일 목록 조회 */ public function getPostFiles(Post $post): Collection { return $post->files()->orderBy('created_at')->get(); } /** * 파일 다운로드 */ public function downloadFile(Post $post, int $fileId) { $file = $post->files()->find($fileId); if (! $file) { abort(404, '파일을 찾을 수 없습니다.'); } return $file->download(); } /** * 파일 미리보기 (인라인 표시) */ public function previewFile(Post $post, int $fileId) { $file = $post->files()->find($fileId); if (! $file) { abort(404, '파일을 찾을 수 없습니다.'); } if (! $file->existsInStorage()) { abort(404, '파일을 찾을 수 없습니다.'); } return response()->file($file->getStoragePath(), [ 'Content-Type' => $file->mime_type, ]); } // ========================================================================= // Comment Management // ========================================================================= /** * 게시글 댓글 목록 조회 (계층형) */ public function getComments(Post $post): Collection { return $post->comments() ->whereNull('parent_id') ->with(['user', 'replies.user']) ->orderBy('created_at') ->get(); } /** * 댓글 생성 */ public function createComment(Post $post, array $data): BoardComment { $data['post_id'] = $post->id; $data['tenant_id'] = $post->tenant_id; $data['user_id'] = auth()->id(); $data['ip_address'] = request()->ip(); $data['status'] = 'active'; return BoardComment::create($data); } /** * 댓글 수정 */ public function updateComment(BoardComment $comment, array $data): BoardComment { $comment->update([ 'content' => $data['content'], 'updated_by' => auth()->id(), ]); return $comment->fresh(); } /** * 댓글 삭제 (Soft Delete) */ public function deleteComment(BoardComment $comment): bool { $comment->deleted_by = auth()->id(); $comment->save(); return $comment->delete(); } }