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:
309
app/Http/Controllers/PostController.php
Normal file
309
app/Http/Controllers/PostController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
222
app/Models/Boards/File.php
Normal 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
211
app/Models/Boards/Post.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
app/Models/Boards/PostCustomFieldValue.php
Normal file
41
app/Models/Boards/PostCustomFieldValue.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
384
app/Services/PostService.php
Normal file
384
app/Services/PostService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user