ofBoard($boardId) ->with(['author', 'board']) ->published(); // 검색 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']); } // 정렬: 공지사항 먼저, 그 다음 최신순 $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 { // 커스텀 필드 값 삭제 $post->customFieldValues()->delete(); return $post->forceDelete(); } /** * 조회수 증가 */ 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): array { return [ '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(), ]; } // ========================================================================= // 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 레코드 생성 $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()), 'fileable_type' => Post::class, 'fileable_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(); } }