Files
sam-manage/app/Services/PostService.php
kent da159cc46e 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>
2025-12-27 17:08:53 +09:00

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