feat(boards): 게시글 파일 시스템 개선 및 이미지 미리보기 추가

- Morph map에 Post, Department 모델 등록 (ClassMorphViolationException 해결)
- 파일 저장 방식을 API 스타일로 변경 (document_id + document_type)
- 파일 미리보기 라우트 및 메서드 추가 (previewFile)
- 게시글 상세 페이지에서 이미지 첨부파일을 본문 상단에 풀 너비로 표시
- 비이미지 첨부파일은 하단에 다운로드 목록으로 분리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 17:08:53 +09:00
parent 541b59173b
commit da159cc46e
7 changed files with 94 additions and 22 deletions

View File

@@ -9,6 +9,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PostController extends Controller
@@ -268,7 +269,7 @@ public function uploadFiles(Board $board, Post $post, Request $request): JsonRes
/**
* 파일 다운로드
*/
public function downloadFile(Board $board, Post $post, int $fileId): StreamedResponse|RedirectResponse
public function downloadFile(Board $board, Post $post, int $fileId): BinaryFileResponse|StreamedResponse|RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
@@ -283,6 +284,24 @@ public function downloadFile(Board $board, Post $post, int $fileId): StreamedRes
return $this->postService->downloadFile($post, $fileId);
}
/**
* 파일 미리보기 (이미지 인라인 표시)
*/
public function previewFile(Board $board, Post $post, int $fileId): BinaryFileResponse|StreamedResponse|RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
abort(404);
}
// 비밀글 접근 권한 확인
if (! $post->canAccess(auth()->user())) {
return back()->with('error', '파일에 접근할 수 없습니다.');
}
return $this->postService->previewFile($post, $fileId);
}
/**
* 파일 삭제 (AJAX)
*/

View File

@@ -45,6 +45,10 @@ class File extends Model
'file_size',
'mime_type',
'file_type',
// New fields (API 방식)
'document_id',
'document_type',
// Legacy fields (하위 호환)
'fileable_id',
'fileable_type',
'description',
@@ -219,4 +223,13 @@ public function scopeNonTemp($query)
{
return $query->where('is_temp', false);
}
/**
* 특정 문서의 파일들
*/
public function scopeForDocument($query, int $documentId, string $documentType)
{
return $query->where('document_id', $documentId)
->where('document_type', $documentType);
}
}

View File

@@ -8,7 +8,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
@@ -138,11 +137,12 @@ public function customFieldValues(): HasMany
}
/**
* 첨부파일 (Polymorphic)
* 첨부파일 (document_id + document_type 기반)
*/
public function files(): MorphMany
public function files(): HasMany
{
return $this->morphMany(File::class, 'fileable');
return $this->hasMany(File::class, 'document_id')
->where('document_type', 'post');
}
// =========================================================================

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Boards\Post;
use App\Models\Tenants\Department;
use App\Models\User;
use App\Services\SidebarMenuService;
use Illuminate\Database\Eloquent\Relations\Relation;
@@ -24,9 +26,11 @@ public function register(): void
*/
public function boot(): void
{
// Morph Map: mng/api 프로젝트 간 User 모델 경로 통일
// Morph Map: Polymorphic 관계 모델 등록
Relation::enforceMorphMap([
'user' => User::class,
'post' => Post::class,
'department' => Department::class,
]);
// 사이드바에 메뉴 데이터 전달

View File

@@ -257,7 +257,7 @@ public function uploadFiles(Post $post, array $files): array
// 파일 저장
Storage::disk('tenant')->put($filePath, file_get_contents($file));
// DB 레코드 생성
// DB 레코드 생성 (API 방식: document_id + document_type)
$fileRecord = File::create([
'tenant_id' => $tenantId,
'is_temp' => false,
@@ -269,8 +269,8 @@ public function uploadFiles(Post $post, array $files): array
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'file_type' => $this->getFileType($file->getMimeType()),
'fileable_type' => Post::class,
'fileable_id' => $post->id,
'document_type' => 'post',
'document_id' => $post->id,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
@@ -381,4 +381,24 @@ public function downloadFile(Post $post, int $fileId)
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,
]);
}
}

View File

@@ -87,6 +87,27 @@ function confirmDeletePost() {
</div>
@endif
<!-- 첨부 이미지 (본문 상단 - 너비) -->
@php
$imageFiles = $post->files->filter(fn($file) => $file->isImage());
$otherFiles = $post->files->filter(fn($file) => !$file->isImage());
@endphp
@if($imageFiles->isNotEmpty())
<div class="px-6 py-4 border-b border-gray-200">
<div class="space-y-4">
@foreach($imageFiles as $file)
<a href="{{ route('boards.posts.files.preview', [$board, $post, $file->id]) }}"
target="_blank"
class="block overflow-hidden rounded-lg border border-gray-200 hover:border-blue-400 transition">
<img src="{{ route('boards.posts.files.preview', [$board, $post, $file->id]) }}"
alt="{{ $file->display_name ?? $file->original_name }}"
class="w-full h-auto">
</a>
@endforeach
</div>
</div>
@endif
<!-- 본문 -->
<div class="px-6 py-8">
<div class="prose max-w-none">
@@ -94,25 +115,19 @@ function confirmDeletePost() {
</div>
</div>
<!-- 첨부파일 -->
@if($post->files->isNotEmpty())
<!-- 첨부파일 (이미지 제외) -->
@if($otherFiles->isNotEmpty())
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
<h3 class="text-sm font-medium text-gray-700 mb-3">
첨부파일 ({{ $post->files->count() }})
첨부파일 ({{ $otherFiles->count() }})
</h3>
<div class="space-y-2">
@foreach($post->files as $file)
@foreach($otherFiles as $file)
<div class="flex items-center justify-between px-4 py-3 bg-white rounded-lg border border-gray-200">
<div class="flex items-center gap-3">
@if($file->isImage())
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
@else
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
@endif
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<div>
<span class="text-sm text-gray-900">{{ $file->display_name ?? $file->original_name }}</span>
<span class="text-xs text-gray-400 ml-2">({{ $file->getFormattedSize() }})</span>

View File

@@ -123,6 +123,7 @@
// 파일 관리
Route::prefix('{post}/files')->name('files.')->group(function () {
Route::get('/{fileId}/download', [PostController::class, 'downloadFile'])->name('download');
Route::get('/{fileId}/preview', [PostController::class, 'previewFile'])->name('preview');
Route::post('/', [PostController::class, 'uploadFiles'])->name('upload');
Route::delete('/{fileId}', [PostController::class, 'deleteFile'])->name('delete');
});