diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php new file mode 100644 index 00000000..eafc6995 --- /dev/null +++ b/app/Http/Controllers/PostController.php @@ -0,0 +1,309 @@ +only(['search', 'is_notice']); + $posts = $this->postService->getPosts($board->id, $filters, 15); + $notices = $this->postService->getNotices($board->id, 5); + $stats = $this->postService->getBoardPostStats($board->id); + + return view('posts.index', compact('board', 'posts', 'notices', 'stats', 'filters')); + } + + /** + * 게시글 작성 폼 + */ + public function create(Board $board): View + { + $fields = $board->fields; + + return view('posts.create', compact('board', 'fields')); + } + + /** + * 게시글 저장 + */ + public function store(Board $board, Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'nullable|string', + 'is_notice' => 'nullable|boolean', + 'is_secret' => 'nullable|boolean', + 'files' => 'nullable|array', + 'files.*' => 'file|max:'.($board->max_file_size), + ]); + + // 커스텀 필드 값 + $customFields = $request->input('custom_fields', []); + + // 커스텀 필드 유효성 검사 + foreach ($board->fields as $field) { + if ($field->is_required && empty($customFields[$field->field_key])) { + return back() + ->withInput() + ->withErrors([$field->field_key => "{$field->name}은(는) 필수입니다."]); + } + } + + $validated['is_notice'] = $request->boolean('is_notice'); + $validated['is_secret'] = $request->boolean('is_secret'); + + $post = $this->postService->createPost($board, $validated, $customFields); + + // 파일 업로드 처리 + if ($request->hasFile('files') && $board->allow_files) { + try { + $this->postService->uploadFiles($post, $request->file('files')); + } catch (\Exception $e) { + // 파일 업로드 실패해도 게시글은 저장됨 - 경고 메시지만 표시 + return redirect() + ->route('boards.posts.show', [$board, $post]) + ->with('warning', '게시글이 작성되었으나 일부 파일 업로드에 실패했습니다: '.$e->getMessage()); + } + } + + return redirect() + ->route('boards.posts.show', [$board, $post]) + ->with('success', '게시글이 작성되었습니다.'); + } + + /** + * 게시글 상세 + */ + public function show(Board $board, Post $post): View|RedirectResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + abort(404); + } + + // 비밀글 접근 권한 확인 + if (! $post->canAccess(auth()->user())) { + return back()->with('error', '비밀글은 작성자만 볼 수 있습니다.'); + } + + // 조회수 증가 + $this->postService->incrementViews($post); + + // 이전/다음 글 + $adjacent = $this->postService->getAdjacentPosts($post); + + // 커스텀 필드 값 + $customFieldValues = $post->getCustomFieldsArray(); + + return view('posts.show', compact('board', 'post', 'adjacent', 'customFieldValues')); + } + + /** + * 게시글 수정 폼 + */ + public function edit(Board $board, Post $post): View|RedirectResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + abort(404); + } + + // 수정 권한 확인 (작성자 또는 관리자) + if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) { + return back()->with('error', '수정 권한이 없습니다.'); + } + + $fields = $board->fields; + $customFieldValues = $post->getCustomFieldsArray(); + + return view('posts.edit', compact('board', 'post', 'fields', 'customFieldValues')); + } + + /** + * 게시글 수정 저장 + */ + public function update(Board $board, Post $post, Request $request): RedirectResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + abort(404); + } + + // 수정 권한 확인 + if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) { + return back()->with('error', '수정 권한이 없습니다.'); + } + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'nullable|string', + 'is_notice' => 'nullable|boolean', + 'is_secret' => 'nullable|boolean', + 'files' => 'nullable|array', + 'files.*' => 'file|max:'.($board->max_file_size), + ]); + + // 커스텀 필드 값 + $customFields = $request->input('custom_fields', []); + + // 커스텀 필드 유효성 검사 + foreach ($board->fields as $field) { + if ($field->is_required && empty($customFields[$field->field_key])) { + return back() + ->withInput() + ->withErrors([$field->field_key => "{$field->name}은(는) 필수입니다."]); + } + } + + $validated['is_notice'] = $request->boolean('is_notice'); + $validated['is_secret'] = $request->boolean('is_secret'); + + $this->postService->updatePost($post, $validated, $customFields); + + // 파일 업로드 처리 + if ($request->hasFile('files') && $board->allow_files) { + try { + $this->postService->uploadFiles($post, $request->file('files')); + } catch (\Exception $e) { + return redirect() + ->route('boards.posts.show', [$board, $post]) + ->with('warning', '게시글이 수정되었으나 일부 파일 업로드에 실패했습니다: '.$e->getMessage()); + } + } + + return redirect() + ->route('boards.posts.show', [$board, $post]) + ->with('success', '게시글이 수정되었습니다.'); + } + + /** + * 게시글 삭제 + */ + public function destroy(Board $board, Post $post): RedirectResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + abort(404); + } + + // 삭제 권한 확인 + if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) { + return back()->with('error', '삭제 권한이 없습니다.'); + } + + // 첨부 파일 삭제 + $this->postService->deleteAllFiles($post); + + $this->postService->deletePost($post); + + return redirect() + ->route('boards.posts.index', $board) + ->with('success', '게시글이 삭제되었습니다.'); + } + + // ========================================================================= + // File Management + // ========================================================================= + + /** + * 파일 업로드 (AJAX) + */ + public function uploadFiles(Board $board, Post $post, Request $request): JsonResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + return response()->json(['success' => false, 'message' => '게시글을 찾을 수 없습니다.'], 404); + } + + // 수정 권한 확인 + if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) { + return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403); + } + + // 파일 첨부 허용 여부 + if (! $board->allow_files) { + return response()->json(['success' => false, 'message' => '이 게시판은 파일 첨부가 허용되지 않습니다.'], 400); + } + + $request->validate([ + 'files' => 'required|array', + 'files.*' => 'file|max:'.($board->max_file_size), + ]); + + try { + $uploadedFiles = $this->postService->uploadFiles($post, $request->file('files')); + + return response()->json([ + 'success' => true, + 'message' => count($uploadedFiles).'개의 파일이 업로드되었습니다.', + 'files' => collect($uploadedFiles)->map(fn ($file) => [ + 'id' => $file->id, + 'name' => $file->display_name ?? $file->original_name, + 'size' => $file->getFormattedSize(), + 'type' => $file->file_type, + ]), + ]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 400); + } + } + + /** + * 파일 다운로드 + */ + public function downloadFile(Board $board, Post $post, int $fileId): StreamedResponse|RedirectResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + abort(404); + } + + // 비밀글 접근 권한 확인 + if (! $post->canAccess(auth()->user())) { + return back()->with('error', '파일에 접근할 수 없습니다.'); + } + + return $this->postService->downloadFile($post, $fileId); + } + + /** + * 파일 삭제 (AJAX) + */ + public function deleteFile(Board $board, Post $post, int $fileId): JsonResponse + { + // 게시판 일치 확인 + if ($post->board_id !== $board->id) { + return response()->json(['success' => false, 'message' => '게시글을 찾을 수 없습니다.'], 404); + } + + // 삭제 권한 확인 (작성자 또는 관리자) + if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) { + return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403); + } + + try { + $this->postService->deleteFile($post, $fileId); + + return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 400); + } + } +} diff --git a/app/Models/Boards/Board.php b/app/Models/Boards/Board.php index 3528c416..3e525eec 100644 --- a/app/Models/Boards/Board.php +++ b/app/Models/Boards/Board.php @@ -104,6 +104,11 @@ public function fields(): HasMany ->orderBy('sort_order'); } + public function posts(): HasMany + { + return $this->hasMany(Post::class, 'board_id'); + } + public function creator(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); diff --git a/app/Models/Boards/File.php b/app/Models/Boards/File.php new file mode 100644 index 00000000..cf8beb16 --- /dev/null +++ b/app/Models/Boards/File.php @@ -0,0 +1,222 @@ + 'boolean', + 'file_size' => 'integer', + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * Polymorphic 관계 - 연결된 모델 (Post, Product 등) + */ + public function fileable(): MorphTo + { + return $this->morphTo(); + } + + /** + * 업로더 + */ + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 스토리지 전체 경로 + */ + public function getStoragePath(): string + { + return Storage::disk('tenant')->path($this->file_path); + } + + /** + * 파일 존재 여부 + */ + public function existsInStorage(): bool + { + return Storage::disk('tenant')->exists($this->file_path); + } + + /** + * 다운로드 응답 + */ + public function download() + { + if (! $this->existsInStorage()) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + $fileName = $this->display_name ?? $this->original_name ?? $this->file_name; + + return response()->download($this->getStoragePath(), $fileName); + } + + /** + * 파일 URL (public 접근용) + */ + public function getUrl(): ?string + { + if (! $this->existsInStorage()) { + return null; + } + + return Storage::disk('tenant')->url($this->file_path); + } + + /** + * 파일 크기 포맷팅 + */ + public function getFormattedSize(): string + { + $bytes = $this->file_size; + + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2).' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2).' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2).' KB'; + } + + return $bytes.' bytes'; + } + + /** + * 파일 확장자 + */ + public function getExtension(): string + { + $fileName = $this->stored_name ?? $this->file_name ?? $this->original_name; + + return pathinfo($fileName, PATHINFO_EXTENSION); + } + + /** + * 이미지 여부 + */ + public function isImage(): bool + { + return str_starts_with($this->mime_type ?? '', 'image/'); + } + + /** + * Soft Delete with user tracking + */ + public function softDeleteFile(int $userId): void + { + $this->deleted_by = $userId; + $this->save(); + $this->delete(); + } + + /** + * 완전 삭제 (물리 파일 포함) + */ + public function permanentDelete(): void + { + if ($this->existsInStorage()) { + Storage::disk('tenant')->delete($this->file_path); + } + + $this->forceDelete(); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 특정 모델의 파일들 + */ + public function scopeForModel($query, string $type, int $id) + { + return $query->where('fileable_type', $type) + ->where('fileable_id', $id); + } + + /** + * 임시 파일만 + */ + public function scopeTemp($query) + { + return $query->where('is_temp', true); + } + + /** + * 임시 파일 제외 + */ + public function scopeNonTemp($query) + { + return $query->where('is_temp', false); + } +} diff --git a/app/Models/Boards/Post.php b/app/Models/Boards/Post.php new file mode 100644 index 00000000..1632f7c8 --- /dev/null +++ b/app/Models/Boards/Post.php @@ -0,0 +1,211 @@ + 'boolean', + 'is_secret' => 'boolean', + 'views' => 'integer', + ]; + + protected $attributes = [ + 'is_notice' => false, + 'is_secret' => false, + 'views' => 0, + 'status' => 'published', + 'editor_type' => 'wysiwyg', + ]; + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 특정 게시판의 글 + */ + public function scopeOfBoard(Builder $query, int $boardId): Builder + { + return $query->where('board_id', $boardId); + } + + /** + * 공지사항만 + */ + public function scopeNotices(Builder $query): Builder + { + return $query->where('is_notice', true); + } + + /** + * 일반 글만 (공지 제외) + */ + public function scopeRegular(Builder $query): Builder + { + return $query->where('is_notice', false); + } + + /** + * 게시된 글만 + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published'); + } + + /** + * 비밀글이 아닌 것만 + */ + public function scopePublic(Builder $query): Builder + { + return $query->where('is_secret', false); + } + + // ========================================================================= + // Relationships + // ========================================================================= + + public function board(): BelongsTo + { + return $this->belongsTo(Board::class, 'board_id'); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function customFieldValues(): HasMany + { + return $this->hasMany(PostCustomFieldValue::class, 'post_id'); + } + + /** + * 첨부파일 (Polymorphic) + */ + public function files(): MorphMany + { + return $this->morphMany(File::class, 'fileable'); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 조회수 증가 + */ + public function incrementViews(): void + { + $this->increment('views'); + } + + /** + * 커스텀 필드 값 가져오기 + */ + public function getCustomFieldValue(string $fieldKey): ?string + { + $fieldValue = $this->customFieldValues() + ->whereHas('field', fn ($q) => $q->where('field_key', $fieldKey)) + ->first(); + + return $fieldValue?->value; + } + + /** + * 커스텀 필드 값들을 배열로 가져오기 + */ + public function getCustomFieldsArray(): array + { + return $this->customFieldValues() + ->with('field') + ->get() + ->mapWithKeys(fn ($v) => [$v->field->field_key => $v->value]) + ->toArray(); + } + + /** + * 비밀글 접근 가능 여부 + */ + public function canAccess(?User $user): bool + { + // 비밀글이 아니면 모두 접근 가능 + if (! $this->is_secret) { + return true; + } + + // 비로그인이면 접근 불가 + if (! $user) { + return false; + } + + // 작성자 본인이면 접근 가능 + if ($this->user_id === $user->id) { + return true; + } + + // 관리자면 접근 가능 + if ($user->hasRole(['admin', 'super-admin'])) { + return true; + } + + return false; + } +} diff --git a/app/Models/Boards/PostCustomFieldValue.php b/app/Models/Boards/PostCustomFieldValue.php new file mode 100644 index 00000000..e38e37b6 --- /dev/null +++ b/app/Models/Boards/PostCustomFieldValue.php @@ -0,0 +1,41 @@ +belongsTo(Post::class, 'post_id'); + } + + public function field(): BelongsTo + { + return $this->belongsTo(BoardSetting::class, 'field_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6e7bb039..a2e74bfe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -174,4 +174,43 @@ public function canAccessMng(): bool { return $this->belongsToHQ() && $this->is_active; } + + /** + * 특정 역할 보유 여부 확인 + * + * @param string|array $roles 역할명 또는 역할명 배열 + */ + public function hasRole(string|array $roles): bool + { + // 슈퍼관리자는 모든 역할 보유 + if ($this->is_super_admin) { + return true; + } + + $roles = is_array($roles) ? $roles : [$roles]; + + // 현재 테넌트 기준 역할 확인 + $currentTenant = $this->currentTenant(); + if (! $currentTenant) { + return false; + } + + $userRoles = $this->getRolesForTenant($currentTenant->id); + + foreach ($roles as $roleName) { + if ($userRoles->contains('name', $roleName)) { + return true; + } + } + + return false; + } + + /** + * 관리자 역할 보유 여부 (admin 또는 super-admin) + */ + public function isAdmin(): bool + { + return $this->is_super_admin || $this->hasRole(['admin', 'super-admin']); + } } diff --git a/app/Services/PostService.php b/app/Services/PostService.php new file mode 100644 index 00000000..95df2e60 --- /dev/null +++ b/app/Services/PostService.php @@ -0,0 +1,384 @@ +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(); + } +} diff --git a/claudedocs/2025-12-02_file-attachment-feature.md b/claudedocs/2025-12-02_file-attachment-feature.md new file mode 100644 index 00000000..6aa44516 --- /dev/null +++ b/claudedocs/2025-12-02_file-attachment-feature.md @@ -0,0 +1,163 @@ +# 게시글 파일 첨부 기능 구현 + 공유 스토리지 설정 + +**작업일**: 2025-12-02 +**저장소**: MNG, API, Docker +**워크플로우**: code-workflow (분석→수정→검증→정리→커밋) + +--- + +## 개요 + +게시판 시스템에 파일 첨부 기능을 추가했습니다. 기존의 `board_files` 테이블 대신 범용 `files` 테이블의 polymorphic 관계를 활용합니다. + +**추가 작업**: API와 MNG 간 파일 공유를 위한 Docker 공유 볼륨 설정 및 S3 마이그레이션 용이한 구조로 변경 + +## 변경 파일 + +### Docker 설정 +| 파일 | 작업 | 설명 | +|------|------|------| +| `docker/docker-compose.yml` | 수정 | sam_storage 공유 볼륨 추가 (api, admin, mng) | + +### API 저장소 +| 파일 | 작업 | 설명 | +|------|------|------| +| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 | +| `database/migrations/2025_12_02_000238_drop_board_files_table.php` | 생성 | board_files 테이블 삭제 | + +### MNG 저장소 +| 파일 | 작업 | 설명 | +|------|------|------| +| `app/Models/Boards/File.php` | 생성 | Polymorphic 파일 모델 | +| `app/Models/Boards/Post.php` | 수정 | files() MorphMany 관계 추가 | +| `app/Services/PostService.php` | 수정 | 파일 업로드/삭제/다운로드 + 경로 패턴 수정 | +| `app/Http/Controllers/PostController.php` | 수정 | 파일 관련 액션 추가 | +| `resources/views/posts/create.blade.php` | 수정 | 파일 업로드 UI | +| `resources/views/posts/show.blade.php` | 수정 | 첨부파일 목록 표시 | +| `resources/views/posts/edit.blade.php` | 수정 | 기존 파일 관리 + 새 파일 업로드 | +| `routes/web.php` | 수정 | 파일 라우트 추가 | +| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 | +| `storage/app/tenants/.gitignore` | 생성 | 업로드 파일 Git 제외 | + +## 기술 상세 + +### Polymorphic 관계 +```php +// Post -> files() MorphMany +$post->files; // Collection of File models + +// File -> fileable() MorphTo +$file->fileable; // Returns Post model +``` + +### 파일 저장 경로 (공유 스토리지) +``` +Docker 볼륨: sam_storage → /var/www/shared-storage +실제 경로: /var/www/shared-storage/tenants/{tenant_id}/posts/{year}/{month}/{stored_name} +DB 저장: {tenant_id}/posts/{year}/{month}/{stored_name} +``` + +### 공유 스토리지 아키텍처 +``` +┌─────────────────────────────────────────────────────────────┐ +│ Docker Volume: sam_storage │ +│ /var/www/shared-storage/tenants │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ API │ │ MNG │ │ Admin │ │ +│ │Container│ │Container│ │Container│ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ +│ └────────────────┴────────────────┘ │ +│ │ │ +│ Storage::disk('tenant') │ +│ │ │ +│ ┌─────────────────────┴─────────────────────┐ │ +│ │ /var/www/shared-storage/tenants │ │ +│ │ ├── {tenant_id}/ │ │ +│ │ │ ├── posts/2025/12/xxx.pdf │ │ +│ │ │ ├── products/2025/12/yyy.jpg │ │ +│ │ │ └── documents/2025/12/zzz.docx │ │ +│ └────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### S3 마이그레이션 방법 +```bash +# .env 설정 변경만으로 S3 전환 가능 +TENANT_STORAGE_DRIVER=s3 +AWS_ACCESS_KEY_ID=your_key +AWS_SECRET_ACCESS_KEY=your_secret +AWS_DEFAULT_REGION=ap-northeast-2 +AWS_BUCKET=sam-storage +``` + +### 게시판 설정 +- `allow_files`: 파일 첨부 허용 여부 +- `max_file_count`: 최대 파일 개수 +- `max_file_size`: 최대 파일 크기 (KB) + +### 새 라우트 +``` +GET boards/{board}/posts/{post}/files/{fileId}/download # 다운로드 +POST boards/{board}/posts/{post}/files # 업로드 (AJAX) +DELETE boards/{board}/posts/{post}/files/{fileId} # 삭제 (AJAX) +``` + +### File 모델 주요 메서드 +- `fileable()`: Polymorphic 관계 +- `download()`: StreamedResponse 반환 +- `getFormattedSize()`: 사람이 읽기 쉬운 파일 크기 +- `isImage()`: 이미지 파일 여부 +- `permanentDelete()`: 실제 파일 + DB 레코드 삭제 + +### PostService 주요 메서드 +- `uploadFiles(Post, array)`: 파일 업로드 및 저장 +- `deleteFile(Post, fileId)`: 파일 소프트 삭제 +- `downloadFile(Post, fileId)`: 파일 다운로드 응답 + +## UI 기능 + +### 글쓰기 (create.blade.php) +- 드래그앤드롭 파일 업로드 영역 ✅ +- 파일 선택 시 미리보기 목록 +- 파일 개수/크기 제한 클라이언트 검증 +- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop) +- **시각적 피드백** (드래그 시 테두리 파란색 변경) + +### 글보기 (show.blade.php) +- 첨부파일 섹션 (파일 있을 때만 표시) +- 이미지/문서 아이콘 구분 +- 다운로드 버튼 + +### 글수정 (edit.blade.php) +- 기존 첨부파일 목록 (삭제 버튼 포함) +- AJAX 파일 삭제 (확인 후 즉시 반영) +- 새 파일 추가 영역 +- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop) +- **시각적 피드백** (드래그 시 테두리 파란색 변경) +- **기존 파일 개수 고려** (최대 파일 수 체크 시 기존 파일 포함) + +## 검증 결과 + +- [x] PHP 문법 검증 통과 +- [x] 라우트 등록 확인 +- [x] tenant 디스크 설정 확인 +- [x] Pint 코드 포맷팅 완료 + +## 다음 단계 (커밋) + +### API 저장소 +```bash +cd /Users/kent/Works/@KD_SAM/SAM/api +git add . +git commit -m "feat(SAM-API): board_files 테이블 삭제 마이그레이션" +``` + +### MNG 저장소 +```bash +cd /Users/kent/Works/@KD_SAM/SAM/mng +git add . +git commit -m "feat(SAM-MNG): 게시글 파일 첨부 기능 구현" +``` \ No newline at end of file diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd9..1c012b3a 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -38,6 +38,24 @@ 'report' => false, ], + /* + |------------------------------------------------------------------ + | Tenant Storage Disk (Shared across API/MNG/Admin) + |------------------------------------------------------------------ + | + | This disk is shared across all applications via Docker volume. + | - Local (Docker): /var/www/shared-storage/tenants + | - S3: Set TENANT_STORAGE_DISK=s3 in .env + | + */ + 'tenant' => [ + 'driver' => env('TENANT_STORAGE_DRIVER', 'local'), + 'root' => env('TENANT_STORAGE_PATH', '/var/www/shared-storage/tenants'), + 'visibility' => 'private', + 'throw' => false, + 'report' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/resources/views/boards/partials/table.blade.php b/resources/views/boards/partials/table.blade.php index ad2ae824..3e809f44 100644 --- a/resources/views/boards/partials/table.blade.php +++ b/resources/views/boards/partials/table.blade.php @@ -14,7 +14,8 @@
@forelse($boards as $board) -{{ $board->name }}
+{{ $board->name }}
+{{ $board->description }}
+ @endif +| 번호 | +제목 | +작성자 | +작성일 | +조회 | +
|---|---|---|---|---|
| + @if($post->is_notice) + + 공지 + + @else + {{ $post->id }} + @endif + | ++ + @if($post->is_secret) + + @endif + {{ $post->title }} + + | ++ {{ $post->author?->name ?? '알 수 없음' }} + | ++ {{ $post->created_at->format('Y-m-d') }} + | ++ {{ number_format($post->views) }} + | +
|
+
+ 게시글이 없습니다. + + 첫 게시글 작성하기 + + |
+ ||||
{{ $board->name }}
+