- 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>
405 lines
12 KiB
PHP
405 lines
12 KiB
PHP
<?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 레코드 생성 (API 방식: document_id + document_type)
|
|
$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()),
|
|
'document_type' => 'post',
|
|
'document_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();
|
|
}
|
|
|
|
/**
|
|
* 파일 미리보기 (인라인 표시)
|
|
*/
|
|
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,
|
|
]);
|
|
}
|
|
}
|