feat: 게시글 파일 첨부 기능 구현

- File 모델 추가 (Polymorphic 관계)
- Post 모델에 files() MorphMany 관계 추가
- PostService 파일 업로드/삭제/다운로드 메서드 추가
- PostController 파일 관련 액션 추가
- 게시글 작성/수정 폼에 드래그앤드롭 파일 업로드 UI
- 게시글 상세에 첨부파일 목록 표시
- tenant 디스크 설정 (공유 스토리지)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 00:53:25 +09:00
parent 7c7c04f8dc
commit 8948aa86d0
15 changed files with 2394 additions and 4 deletions

View File

@@ -0,0 +1,309 @@
<?php
namespace App\Http\Controllers;
use App\Models\Boards\Board;
use App\Models\Boards\Post;
use App\Services\PostService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PostController extends Controller
{
public function __construct(
private readonly PostService $postService
) {}
/**
* 게시글 목록
*/
public function index(Board $board, Request $request): View
{
$filters = $request->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);
}
}
}

View File

@@ -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');

222
app/Models/Boards/File.php Normal file
View File

@@ -0,0 +1,222 @@
<?php
namespace App\Models\Boards;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
/**
* 파일 모델 (Polymorphic)
*
* @property int $id
* @property int|null $tenant_id
* @property int|null $folder_id
* @property bool $is_temp
* @property string $file_path
* @property string|null $display_name
* @property string|null $stored_name
* @property string|null $original_name
* @property int $file_size
* @property string|null $mime_type
* @property string|null $file_type
* @property int|null $fileable_id
* @property string|null $fileable_type
* @property int|null $uploaded_by
*/
class File extends Model
{
use SoftDeletes;
protected $table = 'files';
protected $fillable = [
'tenant_id',
'folder_id',
'is_temp',
'file_path',
'display_name',
'stored_name',
'original_name',
'file_name',
'file_size',
'mime_type',
'file_type',
'fileable_id',
'fileable_type',
'description',
'uploaded_by',
'deleted_by',
'created_by',
'updated_by',
];
protected $casts = [
'is_temp' => '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);
}
}

211
app/Models/Boards/Post.php Normal file
View File

@@ -0,0 +1,211 @@
<?php
namespace App\Models\Boards;
use App\Models\Tenants\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
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;
/**
* 게시글 모델
*
* @property int $id
* @property int|null $tenant_id
* @property int $board_id
* @property int $user_id
* @property string $title
* @property string|null $content
* @property string $editor_type
* @property string|null $ip_address
* @property bool $is_notice
* @property bool $is_secret
* @property int $views
* @property string $status
*/
class Post extends Model
{
use SoftDeletes;
protected $table = 'posts';
protected $fillable = [
'tenant_id',
'board_id',
'user_id',
'title',
'content',
'editor_type',
'ip_address',
'is_notice',
'is_secret',
'views',
'status',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'is_notice' => '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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Boards;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 게시글 커스텀 필드 값 (EAV 값 저장)
*
* @property int $id
* @property int $post_id
* @property int $field_id
* @property string|null $value
*/
class PostCustomFieldValue extends Model
{
protected $table = 'post_custom_field_values';
protected $fillable = [
'post_id',
'field_id',
'value',
'created_by',
'updated_by',
];
// =========================================================================
// Relationships
// =========================================================================
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'post_id');
}
public function field(): BelongsTo
{
return $this->belongsTo(BoardSetting::class, 'field_id');
}
}

View File

@@ -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']);
}
}

View File

@@ -0,0 +1,384 @@
<?php
namespace App\Services;
use App\Models\Boards\Board;
use App\Models\Boards\File;
use App\Models\Boards\Post;
use App\Models\Boards\PostCustomFieldValue;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PostService
{
/**
* 게시글 목록 조회 (페이지네이션)
*/
public function getPosts(int $boardId, array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = Post::query()
->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();
}
}