feat: [boards] 게시판 API 시스템 구현

- BoardController, PostController 추가
- Board, BoardSetting 모델 수정
- BoardService 추가
- FormRequest 클래스 추가
- Swagger 문서 추가 (BoardApi, PostApi)
- 게시판 시스템 필드 마이그레이션 추가
This commit is contained in:
2025-11-30 21:05:33 +09:00
parent d9192045da
commit d27e47108d
14 changed files with 1944 additions and 4 deletions

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Boards\BoardStoreRequest;
use App\Http\Requests\Boards\BoardUpdateRequest;
use App\Services\Boards\BoardService;
/**
* 게시판 API 컨트롤러 (테넌트용)
*
* 테넌트에서 접근 가능한 게시판:
* - 시스템 게시판 (is_system=true) - 읽기만 가능
* - 테넌트 게시판 (tenant_id=현재 테넌트) - CRUD 가능
*/
class BoardController extends Controller
{
public function __construct(
protected BoardService $boardService
) {}
/**
* 접근 가능한 게시판 목록 조회
* - 시스템 게시판 + 테넌트 게시판
*/
public function index()
{
return ApiResponse::handle(function () {
$filters = request()->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'));
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Boards\CommentStoreRequest;
use App\Http\Requests\Boards\PostStoreRequest;
use App\Http\Requests\Boards\PostUpdateRequest;
use App\Services\Boards\PostService;
/**
* 게시글 API 컨트롤러
*
* URL: /v1/boards/{code}/posts
* - {code}: 게시판 코드 (board_code)
*/
class PostController extends Controller
{
public function __construct(
protected PostService $postService
) {}
/**
* 게시글 목록 조회
*/
public function index(string $code)
{
return ApiResponse::handle(function () use ($code) {
$filters = request()->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'));
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Boards;
use Illuminate\Foundation\Http\FormRequest;
class BoardStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'board_code' => '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' => '게시판명']),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Boards;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class BoardUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$boardId = $this->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' => '게시판 코드']),
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Boards;
use Illuminate\Foundation\Http\FormRequest;
class CommentStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'content' => '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]),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Boards;
use Illuminate\Foundation\Http\FormRequest;
class PostStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => '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' => '내용']),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Boards;
use Illuminate\Foundation\Http\FormRequest;
class PostUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => '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',
];
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,320 @@
<?php
namespace App\Services\Boards;
use App\Models\Boards\Board;
use App\Models\Boards\BoardSetting;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class BoardService extends Service
{
// =========================================================================
// 조회 메서드
// =========================================================================
/**
* 접근 가능한 게시판 목록 (시스템 + 테넌트)
*/
public function getAccessibleBoards(array $filters = []): Collection
{
return Board::accessible($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('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;
}
}

View File

@@ -0,0 +1,327 @@
<?php
namespace App\Services\Boards;
use App\Models\Boards\Board;
use App\Models\Boards\BoardComment;
use App\Models\Boards\Post;
use App\Models\Boards\PostCustomFieldValue;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class PostService extends Service
{
// =========================================================================
// 게시글 조회 메서드
// =========================================================================
/**
* 게시글 목록 조회 (페이징)
*/
public function getPostsByBoard(int $boardId, array $filters = [], int $perPage = 15): LengthAwarePaginator
{
return Post::where('board_id', $boardId)
->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();
}
}

236
app/Swagger/v1/BoardApi.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Board", description="게시판 관리")
*
* @OA\Schema(
* schema="Board",
* type="object",
* required={"id","board_code","name"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", nullable=true, example=1, description="테넌트 ID (시스템 게시판은 null)"),
* @OA\Property(property="is_system", type="boolean", example=false, description="시스템 게시판 여부"),
* @OA\Property(property="board_type", type="string", nullable=true, example="notice", description="게시판 유형 (notice, qna, faq 등)"),
* @OA\Property(property="board_code", type="string", example="NOTICE", description="게시판 코드"),
* @OA\Property(property="name", type="string", example="공지사항", description="게시판명"),
* @OA\Property(property="description", type="string", nullable=true, example="전사 공지사항 게시판", description="설명"),
* @OA\Property(property="editor_type", type="string", enum={"wysiwyg","markdown","text"}, example="wysiwyg", description="에디터 타입"),
* @OA\Property(property="allow_files", type="boolean", example=true, description="파일 첨부 허용"),
* @OA\Property(property="max_file_count", type="integer", example=5, description="최대 파일 수"),
* @OA\Property(property="max_file_size", type="integer", example=20480, description="최대 파일 크기 (KB)"),
* @OA\Property(property="extra_settings", type="object", nullable=true, description="추가 설정"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성 여부"),
* @OA\Property(property="created_at", type="string", example="2025-01-01 12:00:00"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01 12:00:00")
* )
*
* @OA\Schema(
* schema="BoardField",
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="board_id", type="integer", example=1),
* @OA\Property(property="field_key", type="string", example="category", description="필드 키"),
* @OA\Property(property="field_label", type="string", example="카테고리", description="필드 라벨"),
* @OA\Property(property="field_type", type="string", example="select", description="필드 타입"),
* @OA\Property(property="is_required", type="boolean", example=false),
* @OA\Property(property="sort_order", type="integer", example=1),
* @OA\Property(property="options", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="BoardCreateRequest",
* type="object",
* required={"board_code","name"},
*
* @OA\Property(property="board_code", type="string", maxLength=50, example="NOTICE"),
* @OA\Property(property="board_type", type="string", nullable=true, maxLength=50, example="notice"),
* @OA\Property(property="name", type="string", maxLength=100, example="공지사항"),
* @OA\Property(property="description", type="string", nullable=true, maxLength=500),
* @OA\Property(property="editor_type", type="string", enum={"wysiwyg","markdown","text"}, example="wysiwyg"),
* @OA\Property(property="allow_files", type="boolean", example=true),
* @OA\Property(property="max_file_count", type="integer", example=5),
* @OA\Property(property="max_file_size", type="integer", example=20480),
* @OA\Property(property="extra_settings", type="object", nullable=true),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
*
* @OA\Schema(
* schema="BoardUpdateRequest",
* type="object",
*
* @OA\Property(property="board_code", type="string", maxLength=50),
* @OA\Property(property="board_type", type="string", nullable=true, maxLength=50),
* @OA\Property(property="name", type="string", maxLength=100),
* @OA\Property(property="description", type="string", nullable=true, maxLength=500),
* @OA\Property(property="editor_type", type="string", enum={"wysiwyg","markdown","text"}),
* @OA\Property(property="allow_files", type="boolean"),
* @OA\Property(property="max_file_count", type="integer"),
* @OA\Property(property="max_file_size", type="integer"),
* @OA\Property(property="extra_settings", type="object", nullable=true),
* @OA\Property(property="is_active", type="boolean")
* )
*/
class BoardApi
{
/**
* @OA\Get(
* path="/api/v1/boards",
* tags={"Board"},
* summary="접근 가능한 게시판 목록 (시스템 + 테넌트)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="board_type", in="query", description="게시판 유형 필터", @OA\Schema(type="string")),
* @OA\Parameter(name="search", in="query", description="게시판명 검색", @OA\Schema(type="string")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Board")))
* })
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/boards/tenant",
* tags={"Board"},
* summary="테넌트 게시판 목록만 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="board_type", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="search", in="query", @OA\Schema(type="string")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Board")))
* })
* )
* )
*/
public function tenantBoards() {}
/**
* @OA\Get(
* path="/api/v1/boards/{code}",
* tags={"Board"},
* summary="게시판 상세 조회 (코드 기반)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Board"))
* })
* ),
*
* @OA\Response(response=404, description="게시판 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/boards",
* tags={"Board"},
* summary="테넌트 게시판 생성",
* description="현재 테넌트에 새 게시판을 생성합니다. 시스템 게시판은 mng에서만 생성 가능합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BoardCreateRequest")),
*
* @OA\Response(response=200, description="생성 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Board"))
* })
* ),
*
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/boards/{id}",
* tags={"Board"},
* summary="테넌트 게시판 수정",
* description="테넌트 게시판만 수정 가능. 시스템 게시판은 수정 불가.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BoardUpdateRequest")),
*
* @OA\Response(response=200, description="수정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Board"))
* })
* ),
*
* @OA\Response(response=404, description="게시판 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/boards/{id}",
* tags={"Board"},
* summary="테넌트 게시판 삭제",
* description="테넌트 게시판만 삭제 가능. 시스템 게시판은 삭제 불가.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="게시판 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Get(
* path="/api/v1/boards/{code}/fields",
* tags={"Board"},
* summary="게시판 커스텀 필드 목록",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/BoardField")))
* })
* ),
*
* @OA\Response(response=404, description="게시판 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function fields() {}
}

314
app/Swagger/v1/PostApi.php Normal file
View File

@@ -0,0 +1,314 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Post", description="게시글 관리")
*
* @OA\Schema(
* schema="Post",
* type="object",
* required={"id","board_id","title"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="board_id", type="integer", example=1),
* @OA\Property(property="user_id", type="integer", example=1, description="작성자 ID"),
* @OA\Property(property="title", type="string", example="공지사항 제목", description="제목"),
* @OA\Property(property="content", type="string", example="공지사항 내용입니다.", description="내용"),
* @OA\Property(property="editor_type", type="string", enum={"wysiwyg","markdown","text"}, example="wysiwyg"),
* @OA\Property(property="ip_address", type="string", nullable=true, example="127.0.0.1"),
* @OA\Property(property="is_notice", type="boolean", example=false, description="공지 여부"),
* @OA\Property(property="is_secret", type="boolean", example=false, description="비밀글 여부"),
* @OA\Property(property="views", type="integer", example=0, description="조회수"),
* @OA\Property(property="status", type="string", enum={"draft","published","hidden"}, example="published"),
* @OA\Property(property="created_at", type="string", example="2025-01-01 12:00:00"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01 12:00:00"),
* @OA\Property(property="board", ref="#/components/schemas/Board"),
* @OA\Property(property="files", type="array", @OA\Items(type="object"))
* )
*
* @OA\Schema(
* schema="PostPagination",
* type="object",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Post")),
* @OA\Property(property="first_page_url", type="string"),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=3),
* @OA\Property(property="last_page_url", type="string"),
* @OA\Property(property="next_page_url", type="string", nullable=true),
* @OA\Property(property="path", type="string"),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="prev_page_url", type="string", nullable=true),
* @OA\Property(property="to", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=50)
* )
*
* @OA\Schema(
* schema="PostCreateRequest",
* type="object",
* required={"title","content"},
*
* @OA\Property(property="title", type="string", maxLength=200, example="게시글 제목"),
* @OA\Property(property="content", type="string", example="게시글 내용입니다."),
* @OA\Property(property="editor_type", type="string", enum={"wysiwyg","markdown","text"}, example="wysiwyg"),
* @OA\Property(property="is_notice", type="boolean", example=false),
* @OA\Property(property="is_secret", type="boolean", example=false),
* @OA\Property(property="status", type="string", enum={"draft","published","hidden"}, example="published"),
* @OA\Property(property="custom_fields", type="object", nullable=true, description="커스텀 필드 값 (field_id: value)")
* )
*
* @OA\Schema(
* schema="PostUpdateRequest",
* type="object",
*
* @OA\Property(property="title", type="string", maxLength=200),
* @OA\Property(property="content", type="string"),
* @OA\Property(property="editor_type", type="string", enum={"wysiwyg","markdown","text"}),
* @OA\Property(property="is_notice", type="boolean"),
* @OA\Property(property="is_secret", type="boolean"),
* @OA\Property(property="status", type="string", enum={"draft","published","hidden"}),
* @OA\Property(property="custom_fields", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="Comment",
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="post_id", type="integer", example=1),
* @OA\Property(property="user_id", type="integer", example=1),
* @OA\Property(property="parent_id", type="integer", nullable=true, description="부모 댓글 ID (대댓글인 경우)"),
* @OA\Property(property="content", type="string", example="댓글 내용"),
* @OA\Property(property="status", type="string", enum={"active","deleted"}, example="active"),
* @OA\Property(property="created_at", type="string", example="2025-01-01 12:00:00"),
* @OA\Property(property="replies", type="array", @OA\Items(ref="#/components/schemas/Comment"))
* )
*
* @OA\Schema(
* schema="CommentCreateRequest",
* type="object",
* required={"content"},
*
* @OA\Property(property="content", type="string", maxLength=2000, example="댓글 내용"),
* @OA\Property(property="parent_id", type="integer", nullable=true, description="대댓글인 경우 부모 댓글 ID")
* )
*/
class PostApi
{
/**
* @OA\Get(
* path="/api/v1/boards/{code}/posts",
* tags={"Post"},
* summary="게시글 목록",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="per_page", in="query", @OA\Schema(type="integer", example=15)),
* @OA\Parameter(name="search", in="query", description="제목/내용 검색", @OA\Schema(type="string")),
* @OA\Parameter(name="is_notice", in="query", @OA\Schema(type="boolean")),
* @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"draft","published","hidden"})),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PostPagination"))
* })
* ),
*
* @OA\Response(response=404, description="게시판 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/boards/{code}/posts/{id}",
* tags={"Post"},
* summary="게시글 상세 (조회수 자동 증가)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Post"))
* })
* ),
*
* @OA\Response(response=404, description="게시글 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/boards/{code}/posts",
* tags={"Post"},
* summary="게시글 작성",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PostCreateRequest")),
*
* @OA\Response(response=200, description="작성 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Post"))
* })
* ),
*
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="게시판 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/boards/{code}/posts/{id}",
* tags={"Post"},
* summary="게시글 수정",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PostUpdateRequest")),
*
* @OA\Response(response=200, description="수정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Post"))
* })
* ),
*
* @OA\Response(response=404, description="게시글 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/boards/{code}/posts/{id}",
* tags={"Post"},
* summary="게시글 삭제",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="게시글 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Get(
* path="/api/v1/boards/{code}/posts/{postId}/comments",
* tags={"Post"},
* summary="댓글 목록",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="postId", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Comment")))
* })
* )
* )
*/
public function comments() {}
/**
* @OA\Post(
* path="/api/v1/boards/{code}/posts/{postId}/comments",
* tags={"Post"},
* summary="댓글 작성",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="postId", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/CommentCreateRequest")),
*
* @OA\Response(response=200, description="작성 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Comment"))
* })
* ),
*
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function storeComment() {}
/**
* @OA\Put(
* path="/api/v1/boards/{code}/posts/{postId}/comments/{commentId}",
* tags={"Post"},
* summary="댓글 수정",
* description="본인이 작성한 댓글만 수정 가능",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="postId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="commentId", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/CommentCreateRequest")),
*
* @OA\Response(response=200, description="수정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Comment"))
* })
* ),
*
* @OA\Response(response=404, description="댓글 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updateComment() {}
/**
* @OA\Delete(
* path="/api/v1/boards/{code}/posts/{postId}/comments/{commentId}",
* tags={"Post"},
* summary="댓글 삭제",
* description="본인이 작성한 댓글만 삭제 가능 (상태가 deleted로 변경됨)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="code", in="path", required=true, description="게시판 코드", @OA\Schema(type="string")),
* @OA\Parameter(name="postId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="commentId", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="댓글 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroyComment() {}
}

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('boards', function (Blueprint $table) {
// tenant_id를 nullable로 변경 (시스템 게시판은 tenant_id가 NULL)
$table->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();
});
}
};