From ab77bab510bbcdcfc816b1808165f962052a4d39 Mon Sep 17 00:00:00 2001 From: kent Date: Sun, 28 Dec 2025 00:53:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20API=20=EC=B6=94=EA=B0=80=20(/api/v1/system?= =?UTF-8?q?-boards)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SystemBoardController: 시스템 게시판 목록/상세/필드 조회 - SystemPostController: 시스템 게시글 CRUD + 댓글 CRUD - BoardService: getSystemBoardByCode(), getTenantBoardByCode() 추가 - PostService: 시스템/테넌트 게시판 전용 메서드 추가 - routes/api.php: /system-boards/* 엔드포인트 12개 추가 - SystemBoardApi.php: Swagger 문서 시스템 게시판 (is_system=true, tenant_id=null)과 테넌트 게시판 (is_system=false, tenant_id={current})의 board_code 중복 가능성으로 인해 별도 엔드포인트로 분리 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../Api/V1/SystemBoardController.php | 66 ++++ .../Api/V1/SystemPostController.php | 152 +++++++++ app/Services/Boards/BoardService.php | 22 ++ app/Services/Boards/PostService.php | 311 +++++++++++++++++- app/Swagger/v1/SystemBoardApi.php | 290 ++++++++++++++++ routes/api.php | 23 ++ 6 files changed, 846 insertions(+), 18 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/SystemBoardController.php create mode 100644 app/Http/Controllers/Api/V1/SystemPostController.php create mode 100644 app/Swagger/v1/SystemBoardApi.php diff --git a/app/Http/Controllers/Api/V1/SystemBoardController.php b/app/Http/Controllers/Api/V1/SystemBoardController.php new file mode 100644 index 0000000..0f890ae --- /dev/null +++ b/app/Http/Controllers/Api/V1/SystemBoardController.php @@ -0,0 +1,66 @@ +only(['board_type', 'search']); + + return $this->boardService->getSystemBoards($filters); + }, __('message.fetched')); + } + + /** + * 시스템 게시판 상세 조회 (code 기반) + */ + public function show(string $code) + { + return ApiResponse::handle(function () use ($code) { + $board = $this->boardService->getSystemBoardByCode($code); + + if (! $board) { + abort(404, __('error.board.not_found')); + } + + return $board->load('customFields'); + }, __('message.fetched')); + } + + /** + * 시스템 게시판 필드 목록 조회 + */ + public function fields(string $code) + { + return ApiResponse::handle(function () use ($code) { + $board = $this->boardService->getSystemBoardByCode($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/SystemPostController.php b/app/Http/Controllers/Api/V1/SystemPostController.php new file mode 100644 index 0000000..dd5a676 --- /dev/null +++ b/app/Http/Controllers/Api/V1/SystemPostController.php @@ -0,0 +1,152 @@ +only(['search', 'is_notice', 'status']); + $perPage = (int) request()->get('per_page', 15); + + return $this->postService->getPostsBySystemBoardCode($code, $filters, $perPage); + }, __('message.fetched')); + } + + /** + * 시스템 게시판 게시글 상세 조회 + */ + public function show(string $code, int $id) + { + return ApiResponse::handle(function () use ($code, $id) { + $post = $this->postService->getPostBySystemBoardCodeAndId($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->createPostBySystemBoardCode($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->updatePostBySystemBoardCode($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->deletePostBySystemBoardCode($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/Services/Boards/BoardService.php b/app/Services/Boards/BoardService.php index 10ec74e..273e604 100644 --- a/app/Services/Boards/BoardService.php +++ b/app/Services/Boards/BoardService.php @@ -74,6 +74,28 @@ public function getBoardByCode(string $code): ?Board ->first(); } + /** + * 시스템 게시판 코드로 조회 + */ + public function getSystemBoardByCode(string $code): ?Board + { + return Board::systemOnly() + ->where('board_code', $code) + ->where('is_active', true) + ->first(); + } + + /** + * 테넌트 게시판 코드로 조회 + */ + public function getTenantBoardByCode(string $code): ?Board + { + return Board::tenantOnly($this->tenantId()) + ->where('board_code', $code) + ->where('is_active', true) + ->first(); + } + /** * 시스템 게시판 ID로 조회 (mng용) */ diff --git a/app/Services/Boards/PostService.php b/app/Services/Boards/PostService.php index 10e511b..9abaf7c 100644 --- a/app/Services/Boards/PostService.php +++ b/app/Services/Boards/PostService.php @@ -18,11 +18,15 @@ class PostService extends Service /** * 게시글 목록 조회 (페이징) + * + * @param bool $isSystemBoard 시스템 게시판 여부 (시스템 게시판은 tenant_id 필터 제외) */ - public function getPostsByBoard(int $boardId, array $filters = [], int $perPage = 15): LengthAwarePaginator + public function getPostsByBoard(int $boardId, array $filters = [], int $perPage = 15, bool $isSystemBoard = false): LengthAwarePaginator { return Post::where('board_id', $boardId) - ->where('tenant_id', $this->tenantId()) + // 시스템 게시판: tenant_id 필터 없음 (모든 게시글 조회) + // 일반 게시판: 현재 테넌트 게시글만 조회 + ->when(! $isSystemBoard, fn ($q) => $q->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']}%") @@ -45,17 +49,58 @@ public function getPostsByBoardCode(string $boardCode, array $filters = [], int ->where('board_code', $boardCode) ->firstOrFail(); - return $this->getPostsByBoard($board->id, $filters, $perPage); + return $this->getPostsByBoard($board->id, $filters, $perPage, $board->is_system); + } + + /** + * 시스템 게시판 게시글 목록 조회 (게시판 코드 기반) + */ + public function getPostsBySystemBoardCode(string $boardCode, array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $board = Board::systemOnly() + ->where('board_code', $boardCode) + ->where('is_active', true) + ->firstOrFail(); + + return $this->getPostsByBoard($board->id, $filters, $perPage, true); + } + + /** + * 테넌트 게시판 게시글 목록 조회 (게시판 코드 기반) + */ + public function getPostsByTenantBoardCode(string $boardCode, array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $board = Board::tenantOnly($this->tenantId()) + ->where('board_code', $boardCode) + ->where('is_active', true) + ->firstOrFail(); + + return $this->getPostsByBoard($board->id, $filters, $perPage, false); } /** * 게시글 단건 조회 + * + * @param bool|null $isSystemBoard 시스템 게시판 여부 (null이면 자동 감지) */ - public function getPost(int $postId): ?Post + public function getPost(int $postId, ?bool $isSystemBoard = null): ?Post { - return Post::with(['files', 'comments.replies']) - ->where('tenant_id', $this->tenantId()) - ->find($postId); + $query = Post::with(['files', 'comments.replies', 'board']); + + if ($isSystemBoard === null) { + // 게시글에서 board 정보로 시스템 게시판 여부 판단 + // 시스템 게시판이면 tenant_id 필터 없음 + $query->where(function ($q) { + $q->where('tenant_id', $this->tenantId()) + ->orWhereHas('board', fn ($b) => $b->where('is_system', true)); + }); + } elseif ($isSystemBoard) { + // 시스템 게시판: tenant_id 필터 없음 + } else { + $query->where('tenant_id', $this->tenantId()); + } + + return $query->find($postId); } /** @@ -85,6 +130,51 @@ public function getPostByCodeAndId(string $boardCode, int $postId): ?Post return null; } + $query = Post::with(['files', 'comments.replies', 'board']) + ->where('board_id', $board->id); + + // 시스템 게시판: tenant_id 필터 없음 + // 일반 게시판: 현재 테넌트 게시글만 조회 + if (! $board->is_system) { + $query->where('tenant_id', $this->tenantId()); + } + + return $query->find($postId); + } + + /** + * 시스템 게시판 코드와 게시글 ID로 조회 + */ + public function getPostBySystemBoardCodeAndId(string $boardCode, int $postId): ?Post + { + $board = Board::systemOnly() + ->where('board_code', $boardCode) + ->where('is_active', true) + ->first(); + + if (! $board) { + return null; + } + + return Post::with(['files', 'comments.replies', 'board']) + ->where('board_id', $board->id) + ->find($postId); + } + + /** + * 테넌트 게시판 코드와 게시글 ID로 조회 + */ + public function getPostByTenantBoardCodeAndId(string $boardCode, int $postId): ?Post + { + $board = Board::tenantOnly($this->tenantId()) + ->where('board_code', $boardCode) + ->where('is_active', true) + ->first(); + + if (! $board) { + return null; + } + return Post::with(['files', 'comments.replies', 'board']) ->where('board_id', $board->id) ->where('tenant_id', $this->tenantId()) @@ -97,11 +187,13 @@ public function getPostByCodeAndId(string $boardCode, int $postId): ?Post /** * 게시글 생성 + * + * @param bool $isSystemBoard 시스템 게시판 여부 (시스템 게시판은 tenant_id = null) */ - public function createPost(int $boardId, array $data): Post + public function createPost(int $boardId, array $data, bool $isSystemBoard = false): Post { $data['board_id'] = $boardId; - $data['tenant_id'] = $this->tenantId(); + $data['tenant_id'] = $isSystemBoard ? null : $this->tenantId(); $data['user_id'] = $this->apiUserId(); $data['ip_address'] = request()->ip(); $data['status'] = $data['status'] ?? 'published'; @@ -126,7 +218,33 @@ public function createPostByBoardCode(string $boardCode, array $data): Post ->where('board_code', $boardCode) ->firstOrFail(); - return $this->createPost($board->id, $data); + return $this->createPost($board->id, $data, $board->is_system); + } + + /** + * 시스템 게시판 게시글 생성 (게시판 코드 기반) + */ + public function createPostBySystemBoardCode(string $boardCode, array $data): Post + { + $board = Board::systemOnly() + ->where('board_code', $boardCode) + ->where('is_active', true) + ->firstOrFail(); + + return $this->createPost($board->id, $data, true); + } + + /** + * 테넌트 게시판 게시글 생성 (게시판 코드 기반) + */ + public function createPostByTenantBoardCode(string $boardCode, array $data): Post + { + $board = Board::tenantOnly($this->tenantId()) + ->where('board_code', $boardCode) + ->where('is_active', true) + ->firstOrFail(); + + return $this->createPost($board->id, $data, false); } // ========================================================================= @@ -135,11 +253,27 @@ public function createPostByBoardCode(string $boardCode, array $data): Post /** * 게시글 수정 + * + * @param bool|null $isSystemBoard 시스템 게시판 여부 (null이면 자동 감지) */ - public function updatePost(int $postId, array $data): ?Post + public function updatePost(int $postId, array $data, ?bool $isSystemBoard = null): ?Post { - $post = Post::where('tenant_id', $this->tenantId()) - ->find($postId); + $query = Post::with('board'); + + if ($isSystemBoard === null) { + // 게시글에서 board 정보로 시스템 게시판 여부 판단 + // 시스템 게시판이면 tenant_id 필터 없음 + $query->where(function ($q) { + $q->where('tenant_id', $this->tenantId()) + ->orWhereHas('board', fn ($b) => $b->where('is_system', true)); + }); + } elseif ($isSystemBoard) { + // 시스템 게시판: tenant_id 필터 없음 + } else { + $query->where('tenant_id', $this->tenantId()); + } + + $post = $query->find($postId); if (! $post) { return null; @@ -168,6 +302,72 @@ public function updatePostByBoardCode(string $boardCode, int $postId, array $dat return null; } + $query = Post::where('board_id', $board->id); + + // 시스템 게시판: tenant_id 필터 없음 + // 일반 게시판: 현재 테넌트 게시글만 + if (! $board->is_system) { + $query->where('tenant_id', $this->tenantId()); + } + + $post = $query->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 updatePostBySystemBoardCode(string $boardCode, int $postId, array $data): ?Post + { + $board = Board::systemOnly() + ->where('board_code', $boardCode) + ->where('is_active', true) + ->first(); + + if (! $board) { + return null; + } + + $post = Post::where('board_id', $board->id)->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 updatePostByTenantBoardCode(string $boardCode, int $postId, array $data): ?Post + { + $board = Board::tenantOnly($this->tenantId()) + ->where('board_code', $boardCode) + ->where('is_active', true) + ->first(); + + if (! $board) { + return null; + } + $post = Post::where('board_id', $board->id) ->where('tenant_id', $this->tenantId()) ->find($postId); @@ -191,11 +391,27 @@ public function updatePostByBoardCode(string $boardCode, int $postId, array $dat /** * 게시글 삭제 (Soft Delete) + * + * @param bool|null $isSystemBoard 시스템 게시판 여부 (null이면 자동 감지) */ - public function deletePost(int $postId): bool + public function deletePost(int $postId, ?bool $isSystemBoard = null): bool { - $post = Post::where('tenant_id', $this->tenantId()) - ->find($postId); + $query = Post::with('board'); + + if ($isSystemBoard === null) { + // 게시글에서 board 정보로 시스템 게시판 여부 판단 + // 시스템 게시판이면 tenant_id 필터 없음 + $query->where(function ($q) { + $q->where('tenant_id', $this->tenantId()) + ->orWhereHas('board', fn ($b) => $b->where('is_system', true)); + }); + } elseif ($isSystemBoard) { + // 시스템 게시판: tenant_id 필터 없음 + } else { + $query->where('tenant_id', $this->tenantId()); + } + + $post = $query->find($postId); if (! $post) { return false; @@ -217,6 +433,60 @@ public function deletePostByBoardCode(string $boardCode, int $postId): bool return false; } + $query = Post::where('board_id', $board->id); + + // 시스템 게시판: tenant_id 필터 없음 + // 일반 게시판: 현재 테넌트 게시글만 + if (! $board->is_system) { + $query->where('tenant_id', $this->tenantId()); + } + + $post = $query->find($postId); + + if (! $post) { + return false; + } + + return $post->delete(); + } + + /** + * 시스템 게시판 게시글 삭제 (게시판 코드 기반) + */ + public function deletePostBySystemBoardCode(string $boardCode, int $postId): bool + { + $board = Board::systemOnly() + ->where('board_code', $boardCode) + ->where('is_active', true) + ->first(); + + if (! $board) { + return false; + } + + $post = Post::where('board_id', $board->id)->find($postId); + + if (! $post) { + return false; + } + + return $post->delete(); + } + + /** + * 테넌트 게시판 게시글 삭제 (게시판 코드 기반) + */ + public function deletePostByTenantBoardCode(string $boardCode, int $postId): bool + { + $board = Board::tenantOnly($this->tenantId()) + ->where('board_code', $boardCode) + ->where('is_active', true) + ->first(); + + if (! $board) { + return false; + } + $post = Post::where('board_id', $board->id) ->where('tenant_id', $this->tenantId()) ->find($postId); @@ -331,12 +601,17 @@ public function getCustomFieldValues(int $postId): Collection /** * 나의 게시글 목록 조회 + * 시스템 게시판(tenant_id = null) 및 테넌트 게시판의 게시글 모두 조회 */ public function getMyPosts(array $filters = [], int $perPage = 15): LengthAwarePaginator { return Post::where('user_id', $this->apiUserId()) - ->where('tenant_id', $this->tenantId()) - ->with(['board:id,board_code,name']) + ->where(function ($q) { + // 시스템 게시판 게시글 또는 현재 테넌트 게시글 + $q->whereNull('tenant_id') + ->orWhere('tenant_id', $this->tenantId()); + }) + ->with(['board:id,board_code,name,is_system']) ->when(isset($filters['board_code']), function ($q) use ($filters) { $q->whereHas('board', fn ($query) => $query->where('board_code', $filters['board_code'])); }) diff --git a/app/Swagger/v1/SystemBoardApi.php b/app/Swagger/v1/SystemBoardApi.php new file mode 100644 index 0000000..b5ec079 --- /dev/null +++ b/app/Swagger/v1/SystemBoardApi.php @@ -0,0 +1,290 @@ +name('v1.item-master.relationships.reorder'); }); + // 시스템 게시판 API (is_system=true, tenant_id=null) + Route::prefix('system-boards')->group(function () { + // 시스템 게시판 목록/상세 + Route::get('/', [SystemBoardController::class, 'index'])->name('v1.system-boards.index'); + Route::get('/{code}', [SystemBoardController::class, 'show'])->name('v1.system-boards.show'); + Route::get('/{code}/fields', [SystemBoardController::class, 'fields'])->name('v1.system-boards.fields'); + + // 시스템 게시글 API + Route::get('/{code}/posts', [SystemPostController::class, 'index'])->name('v1.system-boards.posts.index'); + Route::post('/{code}/posts', [SystemPostController::class, 'store'])->name('v1.system-boards.posts.store'); + Route::get('/{code}/posts/{id}', [SystemPostController::class, 'show'])->name('v1.system-boards.posts.show'); + Route::put('/{code}/posts/{id}', [SystemPostController::class, 'update'])->name('v1.system-boards.posts.update'); + Route::delete('/{code}/posts/{id}', [SystemPostController::class, 'destroy'])->name('v1.system-boards.posts.destroy'); + + // 시스템 댓글 API + Route::get('/{code}/posts/{postId}/comments', [SystemPostController::class, 'comments'])->name('v1.system-boards.posts.comments.index'); + Route::post('/{code}/posts/{postId}/comments', [SystemPostController::class, 'storeComment'])->name('v1.system-boards.posts.comments.store'); + Route::put('/{code}/posts/{postId}/comments/{commentId}', [SystemPostController::class, 'updateComment'])->name('v1.system-boards.posts.comments.update'); + Route::delete('/{code}/posts/{postId}/comments/{commentId}', [SystemPostController::class, 'destroyComment'])->name('v1.system-boards.posts.comments.destroy'); + }); + // 게시판 관리 API (테넌트용) Route::prefix('boards')->group(function () { // 게시판 목록/상세