From d27e47108d6654438ba31e0117b6526ed7447434 Mon Sep 17 00:00:00 2001 From: hskwon Date: Sun, 30 Nov 2025 21:05:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[boards]=20=EA=B2=8C=EC=8B=9C=ED=8C=90?= =?UTF-8?q?=20API=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BoardController, PostController 추가 - Board, BoardSetting 모델 수정 - BoardService 추가 - FormRequest 클래스 추가 - Swagger 문서 추가 (BoardApi, PostApi) - 게시판 시스템 필드 마이그레이션 추가 --- .../Controllers/Api/V1/BoardController.php | 124 +++++++ .../Controllers/Api/V1/PostController.php | 152 ++++++++ .../Requests/Boards/BoardStoreRequest.php | 42 +++ .../Requests/Boards/BoardUpdateRequest.php | 48 +++ .../Requests/Boards/CommentStoreRequest.php | 29 ++ app/Http/Requests/Boards/PostStoreRequest.php | 35 ++ .../Requests/Boards/PostUpdateRequest.php | 27 ++ app/Models/Boards/Board.php | 193 ++++++++++- app/Models/Boards/BoardSetting.php | 40 ++- app/Services/Boards/BoardService.php | 320 +++++++++++++++++ app/Services/Boards/PostService.php | 327 ++++++++++++++++++ app/Swagger/v1/BoardApi.php | 236 +++++++++++++ app/Swagger/v1/PostApi.php | 314 +++++++++++++++++ ...5429_add_system_fields_to_boards_table.php | 61 ++++ 14 files changed, 1944 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/BoardController.php create mode 100644 app/Http/Controllers/Api/V1/PostController.php create mode 100644 app/Http/Requests/Boards/BoardStoreRequest.php create mode 100644 app/Http/Requests/Boards/BoardUpdateRequest.php create mode 100644 app/Http/Requests/Boards/CommentStoreRequest.php create mode 100644 app/Http/Requests/Boards/PostStoreRequest.php create mode 100644 app/Http/Requests/Boards/PostUpdateRequest.php create mode 100644 app/Services/Boards/BoardService.php create mode 100644 app/Services/Boards/PostService.php create mode 100644 app/Swagger/v1/BoardApi.php create mode 100644 app/Swagger/v1/PostApi.php create mode 100644 database/migrations/2025_11_27_205429_add_system_fields_to_boards_table.php diff --git a/app/Http/Controllers/Api/V1/BoardController.php b/app/Http/Controllers/Api/V1/BoardController.php new file mode 100644 index 0000000..0496a75 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BoardController.php @@ -0,0 +1,124 @@ +only(['board_type', 'search']); + + return $this->boardService->getAccessibleBoards($filters); + }, __('message.fetched')); + } + + /** + * 게시판 상세 조회 (코드 기반) + */ + public function show(string $code) + { + return ApiResponse::handle(function () use ($code) { + $board = $this->boardService->getBoardByCode($code); + + if (! $board) { + abort(404, __('error.board.not_found')); + } + + return $board->load('customFields'); + }, __('message.fetched')); + } + + /** + * 테넌트 게시판 생성 + */ + public function store(BoardStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->boardService->createTenantBoard($request->validated()); + }, __('message.created')); + } + + /** + * 테넌트 게시판 수정 + * - 시스템 게시판은 수정 불가 + */ + public function update(BoardUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + $board = $this->boardService->updateTenantBoard($id, $request->validated()); + + if (! $board) { + abort(404, __('error.board.not_found')); + } + + return $board; + }, __('message.updated')); + } + + /** + * 테넌트 게시판 삭제 + * - 시스템 게시판은 삭제 불가 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $deleted = $this->boardService->deleteTenantBoard($id); + + if (! $deleted) { + abort(404, __('error.board.not_found')); + } + + return ['deleted' => true]; + }, __('message.deleted')); + } + + /** + * 테넌트 게시판 목록만 조회 + */ + public function tenantBoards() + { + return ApiResponse::handle(function () { + $filters = request()->only(['board_type', 'search']); + + return $this->boardService->getTenantBoards($filters); + }, __('message.fetched')); + } + + /** + * 게시판 필드 목록 조회 + */ + public function fields(string $code) + { + return ApiResponse::handle(function () use ($code) { + $board = $this->boardService->getBoardByCode($code); + + if (! $board) { + abort(404, __('error.board.not_found')); + } + + return $this->boardService->getBoardFields($board->id); + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/PostController.php b/app/Http/Controllers/Api/V1/PostController.php new file mode 100644 index 0000000..7899349 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PostController.php @@ -0,0 +1,152 @@ +only(['search', 'is_notice', 'status']); + $perPage = (int) request()->get('per_page', 15); + + return $this->postService->getPostsByBoardCode($code, $filters, $perPage); + }, __('message.fetched')); + } + + /** + * 게시글 상세 조회 + */ + public function show(string $code, int $id) + { + return ApiResponse::handle(function () use ($code, $id) { + $post = $this->postService->getPostByCodeAndId($code, $id); + + if (! $post) { + abort(404, __('error.post.not_found')); + } + + // 조회수 증가 + $post->increment('views'); + + // 커스텀 필드 값 추가 + $post->custom_field_values = $this->postService->getCustomFieldValues($id); + + return $post; + }, __('message.fetched')); + } + + /** + * 게시글 작성 + */ + public function store(PostStoreRequest $request, string $code) + { + return ApiResponse::handle(function () use ($request, $code) { + return $this->postService->createPostByBoardCode($code, $request->validated()); + }, __('message.created')); + } + + /** + * 게시글 수정 + */ + public function update(PostUpdateRequest $request, string $code, int $id) + { + return ApiResponse::handle(function () use ($request, $code, $id) { + $post = $this->postService->updatePostByBoardCode($code, $id, $request->validated()); + + if (! $post) { + abort(404, __('error.post.not_found')); + } + + return $post; + }, __('message.updated')); + } + + /** + * 게시글 삭제 + */ + public function destroy(string $code, int $id) + { + return ApiResponse::handle(function () use ($code, $id) { + $deleted = $this->postService->deletePostByBoardCode($code, $id); + + if (! $deleted) { + abort(404, __('error.post.not_found')); + } + + return ['deleted' => true]; + }, __('message.deleted')); + } + + /** + * 댓글 목록 조회 + */ + public function comments(string $code, int $postId) + { + return ApiResponse::handle(function () use ($postId) { + return $this->postService->getComments($postId); + }, __('message.fetched')); + } + + /** + * 댓글 작성 + */ + public function storeComment(CommentStoreRequest $request, string $code, int $postId) + { + return ApiResponse::handle(function () use ($request, $postId) { + return $this->postService->createComment($postId, $request->validated()); + }, __('message.created')); + } + + /** + * 댓글 수정 + */ + public function updateComment(CommentStoreRequest $request, string $code, int $postId, int $commentId) + { + return ApiResponse::handle(function () use ($request, $commentId) { + $comment = $this->postService->updateComment($commentId, $request->validated()); + + if (! $comment) { + abort(404, __('error.comment.not_found')); + } + + return $comment; + }, __('message.updated')); + } + + /** + * 댓글 삭제 + */ + public function destroyComment(string $code, int $postId, int $commentId) + { + return ApiResponse::handle(function () use ($commentId) { + $deleted = $this->postService->deleteComment($commentId); + + if (! $deleted) { + abort(404, __('error.comment.not_found')); + } + + return ['deleted' => true]; + }, __('message.deleted')); + } +} diff --git a/app/Http/Requests/Boards/BoardStoreRequest.php b/app/Http/Requests/Boards/BoardStoreRequest.php new file mode 100644 index 0000000..767c8be --- /dev/null +++ b/app/Http/Requests/Boards/BoardStoreRequest.php @@ -0,0 +1,42 @@ + 'required|string|max:50|unique:boards,board_code', + 'board_type' => 'nullable|string|max:50', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:500', + 'editor_type' => 'sometimes|string|in:wysiwyg,markdown,text', + 'allow_files' => 'sometimes|boolean', + 'max_file_count' => 'sometimes|integer|min:0|max:20', + 'max_file_size' => 'sometimes|integer|min:0|max:102400', + 'extra_settings' => 'nullable|array', + 'extra_settings.permissions' => 'nullable|array', + 'extra_settings.permissions.read' => 'nullable|array', + 'extra_settings.permissions.write' => 'nullable|array', + 'extra_settings.permissions.manage' => 'nullable|array', + 'is_active' => 'sometimes|boolean', + ]; + } + + public function messages(): array + { + return [ + 'board_code.required' => __('validation.required', ['attribute' => '게시판 코드']), + 'board_code.unique' => __('validation.unique', ['attribute' => '게시판 코드']), + 'name.required' => __('validation.required', ['attribute' => '게시판명']), + ]; + } +} diff --git a/app/Http/Requests/Boards/BoardUpdateRequest.php b/app/Http/Requests/Boards/BoardUpdateRequest.php new file mode 100644 index 0000000..8da291f --- /dev/null +++ b/app/Http/Requests/Boards/BoardUpdateRequest.php @@ -0,0 +1,48 @@ +route('id'); + + return [ + 'board_code' => [ + 'sometimes', + 'string', + 'max:50', + Rule::unique('boards', 'board_code')->ignore($boardId), + ], + 'board_type' => 'nullable|string|max:50', + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string|max:500', + 'editor_type' => 'sometimes|string|in:wysiwyg,markdown,text', + 'allow_files' => 'sometimes|boolean', + 'max_file_count' => 'sometimes|integer|min:0|max:20', + 'max_file_size' => 'sometimes|integer|min:0|max:102400', + 'extra_settings' => 'nullable|array', + 'extra_settings.permissions' => 'nullable|array', + 'extra_settings.permissions.read' => 'nullable|array', + 'extra_settings.permissions.write' => 'nullable|array', + 'extra_settings.permissions.manage' => 'nullable|array', + 'is_active' => 'sometimes|boolean', + ]; + } + + public function messages(): array + { + return [ + 'board_code.unique' => __('validation.unique', ['attribute' => '게시판 코드']), + ]; + } +} diff --git a/app/Http/Requests/Boards/CommentStoreRequest.php b/app/Http/Requests/Boards/CommentStoreRequest.php new file mode 100644 index 0000000..80701b3 --- /dev/null +++ b/app/Http/Requests/Boards/CommentStoreRequest.php @@ -0,0 +1,29 @@ + 'required|string|max:2000', + 'parent_id' => 'nullable|integer|exists:board_comments,id', + ]; + } + + public function messages(): array + { + return [ + 'content.required' => __('validation.required', ['attribute' => '댓글 내용']), + 'content.max' => __('validation.max.string', ['attribute' => '댓글 내용', 'max' => 2000]), + ]; + } +} diff --git a/app/Http/Requests/Boards/PostStoreRequest.php b/app/Http/Requests/Boards/PostStoreRequest.php new file mode 100644 index 0000000..2ba4de0 --- /dev/null +++ b/app/Http/Requests/Boards/PostStoreRequest.php @@ -0,0 +1,35 @@ + 'required|string|max:200', + 'content' => 'required|string', + 'editor_type' => 'sometimes|string|in:wysiwyg,markdown,text', + 'is_notice' => 'sometimes|boolean', + 'is_secret' => 'sometimes|boolean', + 'status' => 'sometimes|string|in:draft,published,hidden', + 'custom_fields' => 'nullable|array', + 'custom_fields.*' => 'nullable', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => __('validation.required', ['attribute' => '제목']), + 'content.required' => __('validation.required', ['attribute' => '내용']), + ]; + } +} diff --git a/app/Http/Requests/Boards/PostUpdateRequest.php b/app/Http/Requests/Boards/PostUpdateRequest.php new file mode 100644 index 0000000..04cbb0e --- /dev/null +++ b/app/Http/Requests/Boards/PostUpdateRequest.php @@ -0,0 +1,27 @@ + 'sometimes|string|max:200', + 'content' => 'sometimes|string', + 'editor_type' => 'sometimes|string|in:wysiwyg,markdown,text', + 'is_notice' => 'sometimes|boolean', + 'is_secret' => 'sometimes|boolean', + 'status' => 'sometimes|string|in:draft,published,hidden', + 'custom_fields' => 'nullable|array', + 'custom_fields.*' => 'nullable', + ]; + } +} diff --git a/app/Models/Boards/Board.php b/app/Models/Boards/Board.php index 6c23f42..485e054 100644 --- a/app/Models/Boards/Board.php +++ b/app/Models/Boards/Board.php @@ -2,27 +2,214 @@ namespace App\Models\Boards; +use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; /** + * 게시판 모델 + * + * @property int $id + * @property int|null $tenant_id + * @property bool $is_system 시스템 게시판 여부 (true=전체 테넌트 공개) + * @property string|null $board_type 게시판 유형 (notice, qna, faq, free 등) + * @property string $board_code 게시판 코드 + * @property string $name 게시판명 + * @property string|null $description 설명 + * @property string $editor_type 에디터 타입 + * @property bool $allow_files 파일 첨부 허용 + * @property int $max_file_count 최대 파일 수 + * @property int $max_file_size 최대 파일 크기 (KB) + * @property array|null $extra_settings 추가 설정 (권한, 옵션 등) + * @property bool $is_active 활성 여부 + * * @mixin IdeHelperBoard */ class Board extends Model { + use SoftDeletes; + protected $table = 'boards'; protected $fillable = [ - 'tenant_id', 'board_code', 'name', 'description', 'editor_type', - 'allow_files', 'max_file_count', 'max_file_size', 'extra_settings', 'is_active', + 'tenant_id', + 'is_system', + 'board_type', + 'board_code', + 'name', + 'description', + 'editor_type', + 'allow_files', + 'max_file_count', + 'max_file_size', + 'extra_settings', + 'is_active', + 'created_by', + 'updated_by', + 'deleted_by', ]; + protected $casts = [ + 'is_system' => 'boolean', + 'is_active' => 'boolean', + 'allow_files' => 'boolean', + 'extra_settings' => 'array', + ]; + + protected $attributes = [ + 'is_system' => false, + 'is_active' => true, + 'allow_files' => true, + 'max_file_count' => 5, + 'max_file_size' => 20480, + 'editor_type' => 'wysiwyg', + ]; + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 현재 테넌트에서 접근 가능한 게시판 + * - 시스템 게시판 (is_system = true) + * - 해당 테넌트 게시판 (tenant_id = 현재 테넌트) + */ + public function scopeAccessible(Builder $query, int $tenantId): Builder + { + return $query->where(function ($q) use ($tenantId) { + $q->where('is_system', true) + ->orWhere('tenant_id', $tenantId); + })->where('is_active', true); + } + + /** + * 시스템 게시판만 (mng용) + */ + public function scopeSystemOnly(Builder $query): Builder + { + return $query->where('is_system', true); + } + + /** + * 테넌트 게시판만 + */ + public function scopeTenantOnly(Builder $query, int $tenantId): Builder + { + return $query->where('is_system', false) + ->where('tenant_id', $tenantId); + } + + /** + * 게시판 유형으로 필터링 + */ + public function scopeOfType(Builder $query, string $type): Builder + { + return $query->where('board_type', $type); + } + + // ========================================================================= + // Relationships + // ========================================================================= + public function customFields() { - return $this->hasMany(BoardSetting::class, 'board_id'); + return $this->hasMany(BoardSetting::class, 'board_id') + ->orderBy('sort_order'); + } + + public function fields() + { + return $this->customFields(); } public function posts() { return $this->hasMany(Post::class, 'board_id'); } + + public function tenant() + { + return $this->belongsTo(\App\Models\Tenant::class); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater() + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * extra_settings에서 특정 키 값 가져오기 + */ + public function getSetting(string $key, $default = null) + { + return data_get($this->extra_settings, $key, $default); + } + + /** + * extra_settings에 값 설정하기 + */ + public function setSetting(string $key, $value): self + { + $settings = $this->extra_settings ?? []; + data_set($settings, $key, $value); + $this->extra_settings = $settings; + + return $this; + } + + /** + * 읽기 권한 확인 + */ + public function canRead(User $user): bool + { + $roles = $this->getSetting('permissions.read', ['*']); + + return in_array('*', $roles) || $user->hasAnyRole($roles); + } + + /** + * 쓰기 권한 확인 + */ + public function canWrite(User $user): bool + { + $roles = $this->getSetting('permissions.write', ['*']); + + return in_array('*', $roles) || $user->hasAnyRole($roles); + } + + /** + * 관리 권한 확인 + */ + public function canManage(User $user): bool + { + $roles = $this->getSetting('permissions.manage', ['admin']); + + return $user->hasAnyRole($roles); + } + + /** + * 시스템 게시판 여부 확인 + */ + public function isSystemBoard(): bool + { + return $this->is_system === true; + } + + /** + * 테넌트 게시판 여부 확인 + */ + public function isTenantBoard(): bool + { + return $this->is_system === false; + } } diff --git a/app/Models/Boards/BoardSetting.php b/app/Models/Boards/BoardSetting.php index a8681ae..2aef130 100644 --- a/app/Models/Boards/BoardSetting.php +++ b/app/Models/Boards/BoardSetting.php @@ -5,6 +5,17 @@ use Illuminate\Database\Eloquent\Model; /** + * 게시판 필드 설정 (EAV 스키마 정의) + * + * @property int $id + * @property int $board_id + * @property string $name 필드명 + * @property string $field_key 필드 키 + * @property string $field_type 필드 타입 (text, number, select, date 등) + * @property array|null $field_meta 필드 메타 (옵션, 유효성 등) + * @property bool $is_required 필수 여부 + * @property int $sort_order 정렬 순서 + * * @mixin IdeHelperBoardSetting */ class BoardSetting extends Model @@ -12,11 +23,38 @@ class BoardSetting extends Model protected $table = 'board_settings'; protected $fillable = [ - 'board_id', 'name', 'field_key', 'field_type', 'field_meta', 'is_required', 'sort_order', + 'board_id', + 'name', + 'field_key', + 'field_type', + 'field_meta', + 'is_required', + 'sort_order', + 'created_by', + 'updated_by', + ]; + + protected $casts = [ + 'field_meta' => 'array', + 'is_required' => 'boolean', + 'sort_order' => 'integer', + ]; + + protected $attributes = [ + 'is_required' => false, + 'sort_order' => 0, ]; public function board() { return $this->belongsTo(Board::class, 'board_id'); } + + /** + * field_meta에서 특정 키 값 가져오기 + */ + public function getMeta(string $key, $default = null) + { + return data_get($this->field_meta, $key, $default); + } } diff --git a/app/Services/Boards/BoardService.php b/app/Services/Boards/BoardService.php new file mode 100644 index 0000000..10ec74e --- /dev/null +++ b/app/Services/Boards/BoardService.php @@ -0,0 +1,320 @@ +tenantId()) + ->when(isset($filters['board_type']), fn ($q) => $q->where('board_type', $filters['board_type'])) + ->when(isset($filters['search']), fn ($q) => $q->where('name', 'like', "%{$filters['search']}%")) + ->orderBy('is_system', 'desc') + ->orderBy('name') + ->get(); + } + + /** + * 시스템 게시판 목록 (mng용) + */ + public function getSystemBoards(array $filters = []): Collection + { + return Board::systemOnly() + ->when(isset($filters['board_type']), fn ($q) => $q->where('board_type', $filters['board_type'])) + ->when(isset($filters['search']), fn ($q) => $q->where('name', 'like', "%{$filters['search']}%")) + ->orderBy('name') + ->get(); + } + + /** + * 시스템 게시판 페이징 목록 (mng용) + */ + public function getSystemBoardsPaginated(array $filters = [], int $perPage = 15): LengthAwarePaginator + { + return Board::systemOnly() + ->when(isset($filters['board_type']), fn ($q) => $q->where('board_type', $filters['board_type'])) + ->when(isset($filters['search']), fn ($q) => $q->where('name', 'like', "%{$filters['search']}%")) + ->when(isset($filters['is_active']), fn ($q) => $q->where('is_active', $filters['is_active'])) + ->orderBy('name') + ->paginate($perPage); + } + + /** + * 테넌트 게시판만 가져오기 + */ + public function getTenantBoards(array $filters = []): Collection + { + return Board::tenantOnly($this->tenantId()) + ->when(isset($filters['board_type']), fn ($q) => $q->where('board_type', $filters['board_type'])) + ->when(isset($filters['search']), fn ($q) => $q->where('name', 'like', "%{$filters['search']}%")) + ->orderBy('name') + ->get(); + } + + /** + * 게시판 코드로 조회 (접근 가능한 게시판 중에서) + */ + public function getBoardByCode(string $code): ?Board + { + return Board::accessible($this->tenantId()) + ->where('board_code', $code) + ->first(); + } + + /** + * 시스템 게시판 ID로 조회 (mng용) + */ + public function getSystemBoardById(int $id): ?Board + { + return Board::systemOnly() + ->with('customFields') + ->find($id); + } + + /** + * 게시판 ID로 조회 (상세) + */ + public function getBoardDetail(int $id): ?Board + { + return Board::with('customFields') + ->find($id); + } + + // ========================================================================= + // 생성 메서드 + // ========================================================================= + + /** + * 시스템 게시판 생성 (mng용) + */ + public function createSystemBoard(array $data): Board + { + $data['is_system'] = true; + $data['tenant_id'] = null; + $data['created_by'] = $this->apiUserId(); + + return Board::create($data); + } + + /** + * 테넌트 게시판 생성 (sam용) + */ + public function createTenantBoard(array $data): Board + { + $data['is_system'] = false; + $data['tenant_id'] = $this->tenantId(); + $data['created_by'] = $this->apiUserId(); + + return Board::create($data); + } + + // ========================================================================= + // 수정 메서드 + // ========================================================================= + + /** + * 시스템 게시판 수정 (mng용) + */ + public function updateSystemBoard(int $id, array $data): ?Board + { + $board = Board::systemOnly()->find($id); + + if (! $board) { + return null; + } + + $data['updated_by'] = $this->apiUserId(); + $board->update($data); + + return $board->fresh(); + } + + /** + * 테넌트 게시판 수정 (sam용) + */ + public function updateTenantBoard(int $id, array $data): ?Board + { + $board = Board::tenantOnly($this->tenantId())->find($id); + + if (! $board) { + return null; + } + + $data['updated_by'] = $this->apiUserId(); + $board->update($data); + + return $board->fresh(); + } + + // ========================================================================= + // 삭제 메서드 + // ========================================================================= + + /** + * 시스템 게시판 삭제 (mng용) + */ + public function deleteSystemBoard(int $id): bool + { + $board = Board::systemOnly()->find($id); + + if (! $board) { + return false; + } + + $board->deleted_by = $this->apiUserId(); + $board->save(); + $board->delete(); + + return true; + } + + /** + * 테넌트 게시판 삭제 (sam용) + */ + public function deleteTenantBoard(int $id): bool + { + $board = Board::tenantOnly($this->tenantId())->find($id); + + if (! $board) { + return false; + } + + $board->deleted_by = $this->apiUserId(); + $board->save(); + $board->delete(); + + return true; + } + + // ========================================================================= + // 필드 관리 메서드 + // ========================================================================= + + /** + * 게시판 필드 목록 조회 + */ + public function getBoardFields(int $boardId): Collection + { + return BoardSetting::where('board_id', $boardId) + ->orderBy('sort_order') + ->get(); + } + + /** + * 게시판 필드 추가 + */ + public function addBoardField(int $boardId, array $data): BoardSetting + { + $data['board_id'] = $boardId; + $data['created_by'] = $this->apiUserId(); + + // 기본 정렬 순서 설정 + if (! isset($data['sort_order'])) { + $maxOrder = BoardSetting::where('board_id', $boardId)->max('sort_order') ?? 0; + $data['sort_order'] = $maxOrder + 1; + } + + return BoardSetting::create($data); + } + + /** + * 게시판 필드 수정 + */ + public function updateBoardField(int $fieldId, array $data): ?BoardSetting + { + $field = BoardSetting::find($fieldId); + + if (! $field) { + return null; + } + + $data['updated_by'] = $this->apiUserId(); + $field->update($data); + + return $field->fresh(); + } + + /** + * 게시판 필드 삭제 + */ + public function deleteBoardField(int $fieldId): bool + { + $field = BoardSetting::find($fieldId); + + if (! $field) { + return false; + } + + return $field->delete(); + } + + /** + * 게시판 필드 순서 변경 + */ + public function reorderBoardFields(int $boardId, array $fieldIds): bool + { + foreach ($fieldIds as $order => $fieldId) { + BoardSetting::where('id', $fieldId) + ->where('board_id', $boardId) + ->update(['sort_order' => $order + 1]); + } + + return true; + } + + // ========================================================================= + // 유틸리티 메서드 + // ========================================================================= + + /** + * 게시판 코드 중복 확인 + */ + public function isCodeExists(string $code, ?int $excludeId = null): bool + { + $query = Board::where('board_code', $code); + + // 시스템 게시판이면 시스템 게시판 중에서 확인 + // 테넌트 게시판이면 해당 테넌트 중에서 확인 + $query->where(function ($q) { + $q->where('is_system', true) + ->orWhere('tenant_id', $this->tenantIdOrNull()); + }); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + /** + * 게시판 활성/비활성 토글 + */ + public function toggleActive(int $id): ?Board + { + $board = Board::find($id); + + if (! $board) { + return null; + } + + $board->is_active = ! $board->is_active; + $board->updated_by = $this->apiUserId(); + $board->save(); + + return $board; + } +} diff --git a/app/Services/Boards/PostService.php b/app/Services/Boards/PostService.php new file mode 100644 index 0000000..e23e527 --- /dev/null +++ b/app/Services/Boards/PostService.php @@ -0,0 +1,327 @@ +where('tenant_id', $this->tenantId()) + ->when(isset($filters['search']), function ($q) use ($filters) { + $q->where(function ($query) use ($filters) { + $query->where('title', 'like', "%{$filters['search']}%") + ->orWhere('content', 'like', "%{$filters['search']}%"); + }); + }) + ->when(isset($filters['is_notice']), fn ($q) => $q->where('is_notice', $filters['is_notice'])) + ->when(isset($filters['status']), fn ($q) => $q->where('status', $filters['status'])) + ->orderByDesc('is_notice') + ->orderByDesc('created_at') + ->paginate($perPage); + } + + /** + * 게시글 목록 조회 (게시판 코드 기반) + */ + public function getPostsByBoardCode(string $boardCode, array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $board = Board::accessible($this->tenantId()) + ->where('board_code', $boardCode) + ->firstOrFail(); + + return $this->getPostsByBoard($board->id, $filters, $perPage); + } + + /** + * 게시글 단건 조회 + */ + public function getPost(int $postId): ?Post + { + return Post::with(['files', 'comments.replies']) + ->where('tenant_id', $this->tenantId()) + ->find($postId); + } + + /** + * 게시글 상세 조회 (조회수 증가) + */ + public function getPostWithViewIncrement(int $postId): ?Post + { + $post = $this->getPost($postId); + + if ($post) { + $post->increment('views'); + } + + return $post; + } + + /** + * 게시판 코드와 게시글 ID로 조회 + */ + public function getPostByCodeAndId(string $boardCode, int $postId): ?Post + { + $board = Board::accessible($this->tenantId()) + ->where('board_code', $boardCode) + ->first(); + + if (! $board) { + return null; + } + + return Post::with(['files', 'comments.replies', 'board']) + ->where('board_id', $board->id) + ->where('tenant_id', $this->tenantId()) + ->find($postId); + } + + // ========================================================================= + // 게시글 생성 메서드 + // ========================================================================= + + /** + * 게시글 생성 + */ + public function createPost(int $boardId, array $data): Post + { + $data['board_id'] = $boardId; + $data['tenant_id'] = $this->tenantId(); + $data['user_id'] = $this->apiUserId(); + $data['ip_address'] = request()->ip(); + $data['status'] = $data['status'] ?? 'published'; + $data['views'] = 0; + + $post = Post::create($data); + + // 커스텀 필드 저장 + if (isset($data['custom_fields'])) { + $this->saveCustomFields($post->id, $data['custom_fields']); + } + + return $post->fresh(['board', 'files']); + } + + /** + * 게시글 생성 (게시판 코드 기반) + */ + public function createPostByBoardCode(string $boardCode, array $data): Post + { + $board = Board::accessible($this->tenantId()) + ->where('board_code', $boardCode) + ->firstOrFail(); + + return $this->createPost($board->id, $data); + } + + // ========================================================================= + // 게시글 수정 메서드 + // ========================================================================= + + /** + * 게시글 수정 + */ + public function updatePost(int $postId, array $data): ?Post + { + $post = Post::where('tenant_id', $this->tenantId()) + ->find($postId); + + if (! $post) { + return null; + } + + $post->update($data); + + // 커스텀 필드 업데이트 + if (isset($data['custom_fields'])) { + $this->saveCustomFields($post->id, $data['custom_fields']); + } + + return $post->fresh(['board', 'files']); + } + + /** + * 게시글 수정 (게시판 코드 기반) + */ + public function updatePostByBoardCode(string $boardCode, int $postId, array $data): ?Post + { + $board = Board::accessible($this->tenantId()) + ->where('board_code', $boardCode) + ->first(); + + if (! $board) { + return null; + } + + $post = Post::where('board_id', $board->id) + ->where('tenant_id', $this->tenantId()) + ->find($postId); + + if (! $post) { + return null; + } + + $post->update($data); + + if (isset($data['custom_fields'])) { + $this->saveCustomFields($post->id, $data['custom_fields']); + } + + return $post->fresh(['board', 'files']); + } + + // ========================================================================= + // 게시글 삭제 메서드 + // ========================================================================= + + /** + * 게시글 삭제 (Soft Delete) + */ + public function deletePost(int $postId): bool + { + $post = Post::where('tenant_id', $this->tenantId()) + ->find($postId); + + if (! $post) { + return false; + } + + return $post->delete(); + } + + /** + * 게시글 삭제 (게시판 코드 기반) + */ + public function deletePostByBoardCode(string $boardCode, int $postId): bool + { + $board = Board::accessible($this->tenantId()) + ->where('board_code', $boardCode) + ->first(); + + if (! $board) { + return false; + } + + $post = Post::where('board_id', $board->id) + ->where('tenant_id', $this->tenantId()) + ->find($postId); + + if (! $post) { + return false; + } + + return $post->delete(); + } + + // ========================================================================= + // 댓글 메서드 + // ========================================================================= + + /** + * 댓글 목록 조회 + */ + public function getComments(int $postId): Collection + { + return BoardComment::where('post_id', $postId) + ->whereNull('parent_id') + ->where('status', 'active') + ->with('replies') + ->orderBy('created_at') + ->get(); + } + + /** + * 댓글 작성 + */ + public function createComment(int $postId, array $data): BoardComment + { + $data['post_id'] = $postId; + $data['user_id'] = $this->apiUserId(); + $data['ip_address'] = request()->ip(); + $data['status'] = 'active'; + + return BoardComment::create($data); + } + + /** + * 댓글 수정 + */ + public function updateComment(int $commentId, array $data): ?BoardComment + { + $comment = BoardComment::where('user_id', $this->apiUserId()) + ->find($commentId); + + if (! $comment) { + return null; + } + + $comment->update($data); + + return $comment->fresh(); + } + + /** + * 댓글 삭제 + */ + public function deleteComment(int $commentId): bool + { + $comment = BoardComment::where('user_id', $this->apiUserId()) + ->find($commentId); + + if (! $comment) { + return false; + } + + $comment->status = 'deleted'; + $comment->save(); + + return true; + } + + // ========================================================================= + // 커스텀 필드 메서드 + // ========================================================================= + + /** + * 커스텀 필드 값 저장 + */ + protected function saveCustomFields(int $postId, array $customFields): void + { + foreach ($customFields as $fieldId => $value) { + PostCustomFieldValue::updateOrCreate( + [ + 'post_id' => $postId, + 'field_id' => $fieldId, + ], + [ + 'value' => is_array($value) ? json_encode($value) : $value, + ] + ); + } + } + + /** + * 커스텀 필드 값 조회 + */ + public function getCustomFieldValues(int $postId): Collection + { + return PostCustomFieldValue::where('post_id', $postId) + ->with('field') + ->get(); + } +} diff --git a/app/Swagger/v1/BoardApi.php b/app/Swagger/v1/BoardApi.php new file mode 100644 index 0000000..232afaa --- /dev/null +++ b/app/Swagger/v1/BoardApi.php @@ -0,0 +1,236 @@ +unsignedBigInteger('tenant_id')->nullable()->change(); + + // 시스템 게시판 여부 (true=전체 테넌트 공개) + $table->boolean('is_system') + ->default(false) + ->after('tenant_id') + ->comment('시스템 게시판 여부 (1=전체 테넌트 공개)'); + + // 게시판 유형 (자유 입력: notice, qna, faq, free 등) + $table->string('board_type', 50) + ->nullable() + ->after('is_system') + ->comment('게시판 유형 (notice, qna, faq, free 등)'); + + // Soft Delete 컬럼 추가 + $table->softDeletes()->comment('삭제 일시'); + $table->unsignedBigInteger('deleted_by') + ->nullable() + ->after('deleted_at') + ->comment('삭제자 ID'); + + // 인덱스 추가 + $table->index('is_system', 'idx_boards_is_system'); + $table->index('board_type', 'idx_boards_board_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('boards', function (Blueprint $table) { + // 인덱스 삭제 + $table->dropIndex('idx_boards_is_system'); + $table->dropIndex('idx_boards_board_type'); + + // 컬럼 삭제 + $table->dropColumn(['is_system', 'board_type', 'deleted_by']); + $table->dropSoftDeletes(); + + // tenant_id를 NOT NULL로 복원 + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + } +};