Files
Gnuboard7/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
T
HeuJung cf1d8ab52c fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
2026-08-17 15:46:38 +09:00

1820 lines
75 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Repositories;
use App\Enums\PermissionType;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Search\KeywordSearch;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
/**
* 게시글 Repository
*
* 게시글 데이터 접근 계층을 담당합니다.
*/
class PostRepository implements PostRepositoryInterface
{
use ChecksBoardPermission;
use FormatsBoardDate;
use PaginatesWithDeferredJoin;
/**
* PostRepository 생성자
*/
public function __construct() {}
/**
* 게시판의 게시글 목록을 페이지네이션하여 조회합니다.
*
* @param string $slug 게시판 슬러그
* @param array $filters 필터 조건
* @param int $perPage 페이지당 항목 수 (일반 게시글 기준)
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지)
* @return Paginator 페이지네이션된 게시글 목록 (simplePaginate — COUNT 쿼리 제거)
*/
public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator
{
$currentPage = $filters['page'] ?? request()->input('page', 1);
// 목록 전용 컬럼: content(본문 HTML) 제외 → content_preview로 대체
$listColumns = [
'id', 'board_id', 'user_id', 'parent_id', 'category',
'title', 'author_name', 'content_mode',
'is_notice', 'is_secret', 'status', 'depth',
'view_count', 'comments_count', 'replies_count', 'attachments_count',
'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at',
DB::raw('SUBSTRING(content, 1, 200) as content_preview_raw'),
];
// buildSortedPostList를 사용하여 페이지네이션된 목록 조회
// attachments, board 제거 — 목록에서는 has_attachment(attachments_count) 사용, board는 Controller에서 전달
return $this->buildSortedPostList(
slug: $slug,
columns: $listColumns,
withTrashed: $withTrashed,
relations: ['user', 'user.avatarAttachment', 'thumbnailAttachment'],
withCount: [],
filters: $filters,
perPage: $perPage,
currentPage: $currentPage,
board: $board,
);
}
/**
* 쿼리에 필터를 적용합니다.
*
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건
*/
private function applyFilters($query, array $filters): void
{
// 검색
if (! empty($filters['search'])) {
// FULLTEXT 와 LIKE 는 이스케이프 규칙이 다르다. LIKE 용으로 이스케이프한 문자열을
// MATCH 에 그대로 넘기면 백슬래시가 검색어의 일부로 들어간다.
$rawKeyword = (string) $filters['search'];
$likeKeyword = $this->escapeLikeKeyword($rawKeyword);
$searchField = $filters['search_field'] ?? 'all';
$query->where(function ($q) use ($rawKeyword, $likeKeyword, $searchField) {
// 제목+내용 검색: FULLTEXT 활용 (all, title_content)
// 코어 헬퍼를 거쳐야 BOOLEAN MODE 연산자(+ - * " 등) 입력이 500 이 되지 않는다.
if ($searchField === 'all' || $searchField === 'title_content') {
KeywordSearch::apply($q, ['title', 'content'], $rawKeyword, 'or');
}
// 작성자 검색
if ($searchField === 'all' || $searchField === 'author' || $searchField === 'author_name') {
$q->orWhere('author_name', 'like', "%{$likeKeyword}%")
->orWhereHas('user', function ($uq) use ($likeKeyword) {
$uq->where('name', 'like', "%{$likeKeyword}%")
->orWhere('email', 'like', "%{$likeKeyword}%");
});
}
});
}
// 상태 필터
if (! empty($filters['status'])) {
if ($filters['status'] === 'secret') {
// 비밀글 필터
$query->where('is_secret', true);
} else {
// 일반 상태 필터 (published, blinded, deleted)
$query->where('status', $filters['status']);
}
}
// 분류 필터
if (isset($filters['category']) && $filters['category'] !== '' && $filters['category'] !== null) {
if ($filters['category'] === 'unclassified') {
// 미분류: category가 NULL/빈 문자열이거나, 게시판 설정에 등록되지 않은 분류
$boardCategories = $filters['board_categories'] ?? [];
$query->where(function ($q) use ($boardCategories) {
$q->whereNull('category')->orWhere('category', '');
if (! empty($boardCategories)) {
$q->orWhereNotIn('category', $boardCategories);
}
});
} else {
$query->where('category', $filters['category']);
}
}
// 공지사항 필터
if (isset($filters['is_notice'])) {
$query->where('is_notice', $filters['is_notice']);
}
// 작성자 필터
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
// 작성일 필터 (시작일~종료일, 시작일~, ~종료일 모두 가능)
if (! empty($filters['created_at_from']) && $filters['created_at_from'] !== '') {
// 시작일 00:00:00부터 검색
$query->where('created_at', '>=', $filters['created_at_from'].' 00:00:00');
}
if (! empty($filters['created_at_to']) && $filters['created_at_to'] !== '') {
// 종료일 23:59:59까지 검색
$query->where('created_at', '<=', $filters['created_at_to'].' 23:59:59');
}
}
/**
* 게시글을 생성합니다.
*
* @param string $slug 게시판 슬러그
* @param array $data 게시글 생성 데이터
* @return Post 생성된 게시글 모델
*/
public function create(string $slug, array $data): Post
{
return Post::create($data);
}
/**
* ID로 게시글을 조회합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @return Post|null 게시글 모델 (없으면 null)
*/
public function find(string $slug, int $id): ?Post
{
$board = Board::where('slug', $slug)->first();
return Post::withTrashed()->with(['user'])->where('board_id', $board?->id)->find($id);
}
/**
* ID로 게시글을 조회하며, 없으면 예외를 발생시킵니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @return Post 게시글 모델
*
* @throws ModelNotFoundException
*/
public function findOrFail(string $slug, int $id): Post
{
$board = Board::where('slug', $slug)->firstOrFail();
return Post::withTrashed()->with(['user'])->where('board_id', $board->id)->findOrFail($id);
}
/**
* 게시글을 수정합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param array $data 수정할 데이터
* @return Post 수정된 게시글 모델
*
* @throws ModelNotFoundException
*/
public function update(string $slug, int $id, array $data): Post
{
$post = $this->findOrFail($slug, $id);
$post->update($data);
return $post->fresh();
}
/**
* 게시글을 삭제합니다 (소프트 삭제).
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @return bool 삭제 성공 여부
*
* @throws ModelNotFoundException
*/
public function delete(string $slug, int $id): bool
{
$post = $this->findOrFail($slug, $id);
return $post->delete();
}
/**
* 게시글을 영구 삭제합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @return bool 영구 삭제 성공 여부
*
* @throws ModelNotFoundException
*/
public function forceDelete(string $slug, int $id): bool
{
$post = $this->findOrFail($slug, $id);
return $post->forceDelete();
}
/**
* 게시글 상태를 변경합니다 (블라인드/삭제/복원).
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param string $status 변경할 상태 (published/blinded/deleted)
* @param array $actionLog 작업 이력 데이터
* @param string|null $triggerType 트리거 유형 (admin, report 등)
* @return Post 상태가 변경된 게시글 모델
*
* @throws ModelNotFoundException
*/
public function updateStatus(string $slug, int $id, string $status, array $actionLog, ?string $triggerType = null): Post
{
$post = $this->findOrFail($slug, $id);
// 기존 작업 이력 가져오기
$actionLogs = $post->action_logs ?? [];
$actionLogs[] = $actionLog;
$updateData = [
'status' => $status,
'action_logs' => $actionLogs,
];
// trigger_type이 지정된 경우 함께 업데이트
if ($triggerType !== null) {
$updateData['trigger_type'] = $triggerType;
}
$post->update($updateData);
// deleted → published 또는 deleted → blinded 변경 시 deleted_at 복원
if ($status !== 'deleted' && $post->trashed()) {
$post->restore();
}
$post->refresh();
return $post;
}
/**
* 조회수를 증가시킵니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 게시판 ID (전달 시 슬러그 재조회 생략)
* @return int 증가된 조회수
*/
public function incrementViewCount(string $slug, int $id, ?int $boardId = null): int
{
// 상세 화면은 이미 게시판을 조회한 뒤 여기로 온다. 슬러그로 다시 찾으면 같은 요청에서
// 게시판을 두 번 읽는다 — 호출자가 알고 있으면 그대로 받는다.
if ($boardId === null) {
$boardId = Board::where('slug', $slug)->value('id');
}
Post::where('board_id', $boardId)
->where('id', $id)
->increment('view_count');
// 증가된 값만 필요하다. find() 는 게시판을 또 찾고 user 관계까지 적재하므로
// 조회수 하나 읽자고 쓰기에는 과하다.
return (int) (Post::withTrashed()
->where('board_id', $boardId)
->where('id', $id)
->value('view_count') ?? 0);
}
/**
* 해당 게시판의 게시글이 공지글인지 경량 조회합니다.
*
* 존재하지 않으면 null을 반환합니다. trashed(`deleted_at`) 여부는 고려하지 않으며
* 스코프/권한 체크를 수행하지 않습니다.
*
* @param int $id 게시글 ID
* @param int $boardId 게시판 ID
* @return bool|null 공지 여부 또는 미존재 시 null
*/
public function isNotice(int $id, int $boardId): ?bool
{
$value = Post::withTrashed()
->where('id', $id)
->where('board_id', $boardId)
->value('is_notice');
return $value === null ? null : (bool) $value;
}
/**
* navigation 판별용 게시글 메타(카테고리·부모 ID)를 경량 조회합니다.
*
* isNotice 와 동일하게 trashed 포함, 스코프/권한 체크 없이 조회합니다.
*
* @param int $id 게시글 ID
* @param int $boardId 게시판 ID
* @return array{category: string|null, parent_id: int|null}|null 메타 또는 미존재 시 null
*/
public function getNavigationMeta(int $id, int $boardId): ?array
{
$row = Post::withTrashed()
->where('id', $id)
->where('board_id', $boardId)
->first(['category', 'parent_id']);
if ($row === null) {
return null;
}
return [
'category' => $row->category,
'parent_id' => $row->parent_id !== null ? (int) $row->parent_id : null,
];
}
/**
* 신고 처리를 위한 게시글 상태를 일괄 업데이트합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param array $updates 업데이트할 데이터 (status, trigger_type, deleted_at, action_log)
* @return Post 수정된 게시글
*
* @throws ModelNotFoundException
*/
public function updateStatusBulk(string $slug, int $id, array $updates): Post
{
$board = Board::where('slug', $slug)->firstOrFail();
$post = Post::withTrashed()->where('board_id', $board->id)->findOrFail($id);
// action_log가 있으면 기존 이력에 추가
if (isset($updates['action_log'])) {
$actionLogs = $post->action_logs ?? [];
$actionLogs[] = $updates['action_log'];
$updates['action_logs'] = $actionLogs;
unset($updates['action_log']);
}
// trigger_type 컬럼에 저장 (action_log 내 trigger 값 사용)
if (isset($updates['trigger_type'])) {
// trigger_type은 그대로 update에 포함됨
}
// deleted_at 처리 (SoftDeletes)
$shouldDelete = isset($updates['deleted_at']) && $updates['deleted_at'] !== null;
$shouldRestore = isset($updates['deleted_at']) && $updates['deleted_at'] === null;
// deleted_at은 update에서 제외 (별도 처리)
unset($updates['deleted_at']);
// 상태 및 기타 필드 업데이트
$post->update($updates);
// SoftDelete 처리
if ($shouldDelete && ! $post->trashed()) {
$post->delete();
} elseif ($shouldRestore && $post->trashed()) {
$post->restore();
}
return $post->fresh();
}
/**
* ID로 게시글을 조회하며 댓글/첨부파일 카운트를 포함합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @param Board|null $board 이미 조회한 게시판 모델 (전달 시 board 관계 적재까지 생략)
* @return Post|null 게시글 모델 (카운트 포함)
*/
public function findWithCounts(string $slug, int $id, ?int $boardId = null, ?Board $board = null): ?Post
{
// 호출자가 이미 손에 쥔 Board 를 넘기면 그 인스턴스를 그대로 쓴다 (#519 F3).
// $board 는 아래 답글 로딩에서 참조하므로 어느 경로로 오든 정의돼 있어야 한다
// (초기화가 없으면 boardId 를 받은 경로에서 미정의 변수 경고가 난다).
$boardId = $board?->id ?? $boardId;
if (! $boardId) {
$board = Board::where('slug', $slug)->first();
$boardId = $board?->id;
}
// 관리 권한자는 삭제된 게시글의 하위 데이터(답글/첨부)까지 열람 가능.
// 첨부 eager load 클로저에서도 동일 권한 기준으로 cascade 삭제분을 포함하기 위해
// 조회 이전에 권한을 계산한다 (댓글의 권한 기반 withTrashed 와 비대칭 제거).
$hasDeletePermission = $this->checkBoardPermission($slug, 'admin.control')
|| $this->checkBoardPermission($slug, 'admin.manage')
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
// Board 를 이미 받았으면 관계로 같은 행을 다시 읽지 않는다 (조회 후 setRelation 으로 부착).
$relations = $board ? ['user', 'user.avatarAttachment'] : ['user', 'user.avatarAttachment', 'board'];
$post = Post::withTrashed()
->where('board_id', $boardId)
->with([
...$relations,
'parent' => function ($query) {
$query->withTrashed()
->with('user');
},
'attachments' => function ($query) use ($boardId, $hasDeletePermission) {
$query->where('board_id', $boardId);
// 관리 권한자는 게시글 삭제로 cascade soft delete 된 첨부까지 조회한다.
// 단, 사용자가 글 삭제 전에 직접 지운 첨부(trigger_type='user' 등)는 제외하고
// cascade 분만 포함한다 (살아있는 것 + cascade 만). 댓글의 cascade-only 노출과 일관.
if ($hasDeletePermission) {
$query->withTrashed()
->where(function ($q) {
$q->whereNull('deleted_at')
->orWhere('trigger_type', TriggerType::Cascade->value);
});
}
},
])
->find($id);
// 모든 하위 답글을 재귀적으로 로드하여 트리 구조로 설정
if ($post) {
// 호출자가 넘긴 Board 는 관계로 적재하지 않았으므로 여기서 부착한다.
if ($board) {
$post->setRelation('board', $board);
}
// loadAllDescendantReplies는 board_id만 필요하므로 Board 모델 대신 조회된 board 사용
$board = $board ?? $post->board;
$allReplies = $this->loadAllDescendantReplies($post->id, $board, $hasDeletePermission);
$post->setRelation('replies', $allReplies);
}
return $post;
}
/**
* 특정 게시글의 모든 하위 답글을 재귀적으로 로드합니다.
*
* 직접 자식뿐 아니라 손자, 증손자 등 모든 후손 답글을 한 번의 쿼리로 가져온 후
* 트리 구조(직접 자식만)로 필터링하여 반환합니다.
*
* @param int $postId 부모 게시글 ID
* @param Board|null $board 게시판 모델
* @param bool $withTrashed 삭제된 답글 포함 여부 (관리자 권한 시 true)
* @return \Illuminate\Database\Eloquent\Collection 직접 자식 답글 (각 답글에 하위 replies 관계 설정됨)
*/
private function loadAllDescendantReplies(int $postId, ?Board $board, bool $withTrashed = false): \Illuminate\Database\Eloquent\Collection
{
// 모든 하위 답글을 한 번에 가져오기 (재귀 쿼리 대신 반복 방식)
$allReplies = collect();
$parentIds = [$postId];
while (! empty($parentIds)) {
$query = Post::query()
->whereIn('parent_id', $parentIds)
->when($board, fn ($q) => $q->where('board_id', $board->id))
->with('user');
if ($withTrashed) {
$query->withTrashed();
}
$batch = $query->get();
if ($batch->isEmpty()) {
break;
}
$allReplies = $allReplies->merge($batch);
$parentIds = $batch->pluck('id')->toArray();
}
// 트리 구조로 조합: 각 답글에 하위 replies 관계 설정
$grouped = $allReplies->groupBy('parent_id');
foreach ($allReplies as $reply) {
$children = $grouped->get($reply->id, collect());
$reply->setRelation('replies', $children);
}
// 직접 자식만 반환 (PostResource에서 재귀적으로 replies를 직렬화)
return new \Illuminate\Database\Eloquent\Collection(
$grouped->get($postId, collect())->all()
);
}
/**
* 전체 일반 게시글(원글) 수를 조회합니다.
* 필터가 적용된 경우 필터 조건을 만족하는 일반 게시글 수를 반환합니다.
*
* @param string $slug 게시판 슬러그
* @param array $filters 필터 조건
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @return BoundedCount 일반 게시글 수 + 정확도 (답글, 공지 제외)
*/
public function countNormalPosts(string $slug, array $filters = [], bool $withTrashed = false): BoundedCount
{
$board = Board::where('slug', $slug)->first();
// 권한 스코프 필터링용 permission identifier (Service에서 컨텍스트 기반으로 전달)
$scopePermission = $filters['scope_permission'] ?? "sirsoft-board.{$slug}.admin.posts.read";
unset($filters['scope_permission']);
$query = Post::query()
->where('board_id', $board?->id)
->where('is_notice', false) // 공지글 제외
->whereNull('parent_id'); // 원글만 (답글 제외)
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($query, $scopePermission);
if ($withTrashed) {
$query->withTrashed();
}
// 필터 적용
$this->applyFilters($query, $filters);
// 총 건수는 상한까지만 센다. 검색어가 걸린 목록은 매칭 수가 데이터 증가에 비례하고,
// 이 값은 목록 화면이 열릴 때마다 계산된다. 상한 이하면 지금과 값이 같고, 초과할
// 때만 "이상" 으로 보고한다 — 페이지 이동은 simplePaginate 의 per_page + 1 실측이
// 담당하므로 상한과 무관하게 끝까지 열려 있다.
return BoundedPaginator::count($query, PaginationLimits::resultCap('board.posts'));
}
/**
* 이전/다음 게시글을 조회합니다.
* buildSortedPostList 메서드를 사용하여 목록 정렬 방식과 동일하게 처리합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 현재 게시글 ID
* @param array $filters 정렬 파라미터 (order_by, order_direction)
* @param bool $withTrashed 삭제된 게시글 포함 여부 (기본: false)
* @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @return array{prev: Post|null, next: Post|null} 이전/다음 게시글
*/
public function getAdjacentPosts(string $slug, int $id, array $filters = [], bool $withTrashed = false, ?int $boardId = null): array
{
// boardId가 전달되면 Board 모델 재조회 없이 직접 사용
if (! $boardId) {
$board = Board::where('slug', $slug)->first();
if (! $board) {
return ['prev' => null, 'next' => null];
}
$boardId = $board->id;
}
$category = $filters['category'] ?? null;
$orderBy = $filters['order_by'] ?? 'id';
$orderDirection = $filters['order_direction'] ?? 'desc';
// Enum 객체를 문자열로 변환 (buildSortedPostList와 동일 패턴)
if ($orderBy instanceof \BackedEnum) {
$orderBy = $orderBy->value;
}
if ($orderDirection instanceof \BackedEnum) {
$orderDirection = $orderDirection->value;
}
// Enum 값 → 실제 DB 컬럼 매핑 (author → author_name)
$columnMapping = [
'author' => 'author_name',
];
if (isset($columnMapping[$orderBy])) {
$orderBy = $columnMapping[$orderBy];
}
// 허용된 정렬 컬럼 화이트리스트 (SQL 인젝션 방지)
$allowedColumns = ['id', 'view_count', 'created_at', 'title', 'author_name'];
if (! in_array($orderBy, $allowedColumns)) {
$orderBy = 'id';
}
$orderDirection = in_array(strtolower($orderDirection), ['asc', 'desc']) ? strtolower($orderDirection) : 'desc';
$currentPost = Post::find($id, [$orderBy, 'id']);
if ($currentPost === null) {
return ['prev' => null, 'next' => null];
}
$currentValue = $currentPost->{$orderBy};
// 기본 조건: 공지 제외, 원글만, 게시 상태
$baseQuery = fn () => Post::query()
->where('board_id', $boardId)
->where('is_notice', false)
->whereNull('parent_id')
->where('status', PostStatus::Published->value)
->when(! $withTrashed, fn ($q) => $q->whereNull('deleted_at'))
->when($category, fn ($q) => $q->where('category', $category))
->select(['id', 'title']);
// 이전/다음 글 조회 (2단계: strict 비교 → tie-breaking)
// OR 조건은 MySQL 옵티마이저가 인덱스를 사용하지 못하므로
// strict 비교(< or >)를 먼저 시도하고, 결과가 없으면 동일 값 tie-breaking 쿼리 실행
$prev = $this->findAdjacentPost(
$baseQuery, $orderBy, $orderDirection, $currentValue, $id, 'prev'
);
$next = $this->findAdjacentPost(
$baseQuery, $orderBy, $orderDirection, $currentValue, $id, 'next'
);
return ['prev' => $prev, 'next' => $next];
}
/**
* 이전 또는 다음 게시글을 조회합니다. (인덱스 최적화)
*
* OR 조건은 MySQL 옵티마이저가 인덱스를 사용하지 못하므로,
* strict 비교를 먼저 시도하고 결과가 없으면 동일 값 tie-breaking 쿼리를 실행합니다.
*
* @param \Closure $baseQuery 기본 쿼리 팩토리
* @param string $orderBy 정렬 컬럼
* @param string $orderDirection 정렬 방향 (asc/desc)
* @param mixed $currentValue 현재 게시글의 정렬 값
* @param int $id 현재 게시글 ID
* @param string $direction 조회 방향 (prev/next)
* @return Post|null 이전/다음 게시글
*/
private function findAdjacentPost(\Closure $baseQuery, string $orderBy, string $orderDirection, mixed $currentValue, int $id, string $direction): ?Post
{
$isPrev = $direction === 'prev';
// prev: 정렬 기준으로 현재 글보다 앞 → desc면 >, asc면 <
// next: 정렬 기준으로 현재 글보다 뒤 → desc면 <, asc면 >
$strictOp = match (true) {
$isPrev && $orderDirection === 'desc' => '>',
$isPrev && $orderDirection === 'asc' => '<',
! $isPrev && $orderDirection === 'desc' => '<',
default => '>',
};
$sortDir = $isPrev
? ($orderDirection === 'desc' ? 'asc' : 'desc')
: $orderDirection;
// DESC 정렬: 목록은 ORDER BY col DESC, id DESC
// prev(위쪽) = 같은 값 내에서 id > current → idOp='>', idSort='asc'(가장 가까운 것)
// next(아래쪽) = 같은 값 내에서 id < current → idOp='<', idSort='desc'(가장 가까운 것)
// ASC 정렬: 목록은 ORDER BY col ASC, id ASC
// prev(위쪽) = 같은 값 내에서 id < current → idOp='<', idSort='desc'
// next(아래쪽) = 같은 값 내에서 id > current → idOp='>', idSort='asc'
$idOp = ($isPrev xor $orderDirection === 'asc') ? '>' : '<';
$idSort = $idOp === '>' ? 'asc' : 'desc';
// 1단계: 동일 정렬 값 내 tie-breaking (id 비교)
// 동일 값 내의 글이 정렬상 더 가까우므로 먼저 확인
$tieQuery = $baseQuery()
->where($orderBy, $currentValue)
->where('id', $idOp, $id)
->orderBy('id', $idSort);
$result = $tieQuery->first();
if ($result) {
return $result;
}
// 2단계: 동일 값 내에 없으면 다른 정렬 값으로 이동
if ($orderBy === 'created_at') {
// created_at: 서브쿼리 MAX/MIN으로 정확한 값을 먼저 찾고 등호 조회
// → 콜드 스타트(버퍼 풀 미적재)에서도 ~2ms (range scan 대비 100배 이상 빠름)
$aggregateFunc = $strictOp === '<' ? 'MAX' : 'MIN';
$subQuery = $baseQuery()
->select(DB::raw("{$aggregateFunc}({$orderBy})"))
->where($orderBy, $strictOp, $currentValue);
// 서브쿼리는 빌더 그대로 넘긴다 — toSql() 로 문자열을 붙이고 mergeBindings 로
// 바인딩을 손수 옮기면 조건 추가 순서에 따라 바인딩이 어긋날 수 있다.
return $baseQuery()
->where($orderBy, '=', $subQuery)
->orderBy('id', $idSort)
->first();
}
// 그 외 컬럼: strict 비교 (인덱스 range scan)
return $baseQuery()
->where($orderBy, $strictOp, $currentValue)
->orderBy($orderBy, $sortDir)
->orderBy('id', $idSort)
->first();
}
/**
* 목록 정렬 방식대로 게시글 리스트를 생성합니다.
* (공지 + 원글 + 답글을 정렬된 순서로 반환)
*
* @param string $slug 게시판 슬러그
* @param array $columns 조회할 컬럼 목록
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @param array $relations Eager Load 관계
* @param array $withCount 카운트 관계
* @param array $filters 필터 조건 (검색, 상태, 분류 등)
* @param int|null $perPage 페이지당 원글 수 (null이면 전체 조회)
* @param int $currentPage 현재 페이지 번호
* @return Collection|LengthAwarePaginator 정렬된 게시글 컬렉션 또는 페이지네이터
*/
private function buildSortedPostList(
string $slug,
array $columns = ['*'],
bool $withTrashed = false,
array $relations = [],
array $withCount = [],
array $filters = [],
?int $perPage = null,
int $currentPage = 1,
?Board $board = null
) {
// board가 전달되지 않은 경우에만 DB 조회 (하위 호환 유지)
if (! $board) {
$board = Board::where('slug', $slug)->first();
}
$boardId = $board?->id;
// Eager loading(with)의 관계에 board_id 조건을 명시적으로 바인딩
// (모델 관계 정의에서 $this->board_id를 사용하면 Eager loading 시 null이 되는 문제 해결)
$relations = $this->bindBoardIdToRelations($relations, $boardId);
// 권한 스코프 필터링용 permission identifier (Service에서 컨텍스트 기반으로 전달)
$postPermission = $filters['scope_permission'] ?? "sirsoft-board.{$slug}.admin.posts.read";
unset($filters['scope_permission']);
// 1단계: 공지글 조회 (첫 페이지에만 표시, 필터 미적용)
$notices = collect([]);
if ($currentPage == 1) {
$noticeQuery = Post::query()
->where('board_id', $boardId)
->where('is_notice', true)
->whereNull('parent_id')
->orderBy('created_at', 'desc');
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($noticeQuery, $postPermission);
// withTrashed를 사용하지 않으면 삭제되지 않은 것만 조회
if ($withTrashed) {
$noticeQuery->withTrashed();
} else {
$noticeQuery->whereNull('deleted_at');
}
if (! empty($relations)) {
$noticeQuery->with($relations);
}
// 공지는 운영자가 등록하는 데이터라 통상 소수지만, 개수를 막는 장치가 없으면
// 잘못 늘어난 게시판에서 한 페이지를 여는 것만으로 전량이 메모리에 올라온다.
// `created_at desc` 정렬이므로 상한에 걸릴 때 최신 공지가 우선 보존된다 —
// 잘린 공지는 후속 페이지에 노출될 경로가 없어 이 순서가 유일한 안전판이다.
// 조정은 코어 필터 훅 `core.pagination.filter_result_cap` (context: board.notices).
$noticeCap = PaginationLimits::resultCap('board.notices');
if ($noticeCap !== null) {
$noticeQuery->limit($noticeCap);
}
$notices = $noticeQuery->get($columns);
}
// 2단계: 원글 조회 (공지 제외)
$parentQuery = Post::query()
->where('board_id', $boardId)
->where('is_notice', false)
->whereNull('parent_id');
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($parentQuery, $postPermission);
// withTrashed를 사용하지 않으면 삭제되지 않은 것만 조회
if ($withTrashed) {
$parentQuery->withTrashed();
} else {
$parentQuery->whereNull('deleted_at');
}
// 필터 적용 (원글만 검색, 답글은 3단계에서 별도 필터링)
$this->applyFilters($parentQuery, $filters);
// 정렬 (order_by 파라미터 사용, 기본값: id)
$orderBy = $filters['order_by'] ?? 'id';
$orderDirection = $filters['order_direction'] ?? 'desc';
// Enum 객체를 문자열로 변환
if ($orderBy instanceof \BackedEnum) {
$orderBy = $orderBy->value;
}
if ($orderDirection instanceof \BackedEnum) {
$orderDirection = $orderDirection->value;
}
// 허용된 정렬 컬럼 목록 (보안을 위한 화이트리스트)
$allowedOrderColumns = ['id', 'view_count', 'created_at', 'title', 'author_name'];
// Enum 값 → 실제 DB 컬럼 매핑 (author → author_name)
$columnMapping = [
'author' => 'author_name',
];
if (isset($columnMapping[$orderBy])) {
$orderBy = $columnMapping[$orderBy];
}
if (! in_array($orderBy, $allowedOrderColumns)) {
$orderBy = 'id'; // 기본값으로 폴백
}
// 정렬 방향 검증 (asc 또는 desc만 허용)
$orderDirection = strtolower($orderDirection);
if (! in_array($orderDirection, ['asc', 'desc'])) {
$orderDirection = 'desc'; // 기본값으로 폴백
}
// 정렬 스펙 (id를 2차 정렬로 추가 — 동일 값 내 순서를 결정론적으로 보장)
// created_at은 초 단위라 실질적 중복이 드물지만, view_count/title/author_name은 중복이 많음
$sort = [['column' => $orderBy, 'direction' => $orderDirection]];
// 페이지네이션 여부에 따라 분기
if ($perPage !== null) {
// 지연 조인 — 이번 페이지의 원글 ID 를 먼저 구하고, 그 ID 에 대해서만 목록 컬럼과
// 관계를 읽는다. OFFSET 이 건너뛰는 원글의 본문 앞부분(SUBSTRING)까지 읽던 비용이
// 사라진다. COUNT 는 기존과 같이 수행하지 않는다(total 은 Service 캐시 카운트).
$paginator = $this->paginateWithDeferredJoin(
query: $parentQuery,
columns: $columns,
sort: $sort,
perPage: $perPage,
page: (int) $currentPage,
relations: $relations,
simple: true,
);
$parents = $paginator->getCollection();
} else {
// 전체 조회
$parentQuery->orderBy($orderBy, $orderDirection)->orderBy('id', $orderDirection);
if (! empty($relations)) {
$parentQuery->with($relations);
}
$parents = $parentQuery->get($columns);
$paginator = null;
}
// 3단계: 모든 하위 답글 조회 (모든 depth 처리 — depth-1만이 아닌 depth-2+ 포함)
//
// 깊이별로 한 번씩 조회하되 누적 건수에 상한을 둔다. 상한이 없으면 답글이 많은
// 게시판에서 한 페이지를 여는 것만으로 그 게시판의 답글 전량을 메모리에 올린다.
$parentIds = $parents->pluck('id')->toArray();
$replyCap = PaginationLimits::resultCap('board.reply_tree');
$allReplies = collect([]);
if (! empty($parentIds)) {
$currentLevelIds = $parentIds;
$seenIds = array_flip($parentIds);
while (! empty($currentLevelIds)) {
$levelQuery = Post::query()
->where('board_id', $boardId)
->whereIn('parent_id', $currentLevelIds)
->orderBy('id', 'asc');
if ($withTrashed) {
$levelQuery->withTrashed();
} else {
$levelQuery->whereNull('deleted_at');
}
if (! empty($relations)) {
$levelQuery->with($relations);
}
if ($replyCap !== null) {
$levelQuery->limit(max(1, $replyCap - $allReplies->count()));
}
$levelReplies = $levelQuery->get($columns);
if ($levelReplies->isEmpty()) {
break;
}
$allReplies = $allReplies->merge($levelReplies);
$currentLevelIds = $levelReplies->pluck('id')->toArray();
// 오염된 데이터(순환 참조)에서도 유한 종료를 보장한다.
$currentLevelIds = array_values(array_filter(
$currentLevelIds,
function ($id) use (&$seenIds) {
if (isset($seenIds[$id])) {
return false;
}
$seenIds[$id] = true;
return true;
}
));
if ($replyCap !== null && $allReplies->count() >= $replyCap) {
break;
}
}
}
$replies = $allReplies;
// 4단계: 병합 (원글 + 모든 하위 답글을 깊이 우선 순으로)
//
// 부모별로 한 번만 그룹지어 둔다. 노드마다 전체 컬렉션을 where 로 훑으면
// 답글 수의 제곱에 비례해 비교가 늘어난다.
$repliesByParent = $replies->sortBy('id')->groupBy('parent_id');
$mergedItems = collect([]);
$appendReplies = function (int $postId) use (&$appendReplies, &$mergedItems, $repliesByParent): void {
foreach ($repliesByParent->get($postId) ?? [] as $reply) {
$mergedItems->push($reply);
$appendReplies($reply->id);
}
};
foreach ($parents as $parent) {
$mergedItems->push($parent);
$appendReplies($parent->id);
}
// 5단계: 공지글을 맨 앞에 추가
$finalItems = $notices->merge($mergedItems);
// 페이지네이션 사용 시 paginator에 최종 컬렉션 설정
if ($paginator !== null) {
$paginator->setCollection($finalItems);
return $paginator;
}
// 전체 조회 시 컬렉션 반환
return $finalItems;
}
/**
* 사용자의 게시판 활동 통계를 조회합니다.
*
* 작성한 게시글 수, 작성한 댓글 수, 총 조회수를 반환합니다.
*
* @param int $userId 사용자 ID
* @return array{total_posts: int, total_comments: int, total_views: int} 활동 통계
*/
public function getUserActivityStats(int $userId): array
{
// 비활성 게시판 제외
$inactiveBoardIds = $this->getInactiveBoardIds();
// 쿼리 1: COUNT — idx_board_posts_user_activity 커버링 (Using index)
$postsQuery = Post::where('user_id', $userId);
if (! empty($inactiveBoardIds)) {
$postsQuery->whereNotIn('board_id', $inactiveBoardIds);
}
$totalPosts = $postsQuery->count();
// 쿼리 2: SUM(comments_count) + SUM(view_count) — idx_board_posts_user_board_stats 커버링
// comments_count 는 PostCountSyncListener 가 정확히 동기화하므로 SUM 으로 대체 (JOIN 제거)
// 소프트 삭제된 게시글은 통계에서 제외 (이슈 #104 요구사항) — withTrashed 미사용
$statsQuery = Post::where('user_id', $userId);
if (! empty($inactiveBoardIds)) {
$statsQuery->whereNotIn('board_id', $inactiveBoardIds);
}
$sums = $statsQuery->selectRaw('COALESCE(SUM(comments_count), 0) as total_comments, COALESCE(SUM(view_count), 0) as total_views')
->first();
return [
'total_posts' => $totalPosts,
'total_comments' => (int) $sums->total_comments,
'total_views' => (int) $sums->total_views,
];
}
/**
* 사용자의 공개 게시글/댓글 통계를 조회합니다 (공개 프로필용).
*
* 기존 getUserActivityStats()와 다른 점:
* - status='published' 조건 적용
* - comments_count = 실제 작성한 댓글 수 (댓글 단 게시글 수가 아님)
*
* @param int $userId 사용자 ID
* @return array{posts_count: int, comments_count: int} 공개 게시글/댓글 통계
*/
public function getUserPublicStats(int $userId): array
{
// 단일 테이블 단일 쿼리
$postsCount = Post::where('user_id', $userId)
->where('status', PostStatus::Published->value)
->count();
$commentsCount = Comment::where('user_id', $userId)
->where('status', PostStatus::Published->value)
->count();
return [
'posts_count' => $postsCount,
'comments_count' => $commentsCount,
];
}
/**
* 사용자의 게시글 활동 목록을 조회합니다.
*
* 사용자가 작성한 게시글, 댓글을 단 게시글을 통합하여 반환합니다.
*
* @param int $userId 사용자 ID
* @param array $filters 필터 조건 (board_slug, search, activity_type, sort, viewer_id, exclude_board_slugs)
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 게시글 활동 목록
*/
public function getUserActivities(int $userId, array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$boardSlugFilter = $filters['board_slug'] ?? null;
$search = $filters['search'] ?? null;
$activityType = $filters['activity_type'] ?? 'authored';
$sort = $filters['sort'] ?? 'latest'; // latest, oldest, views
// 열람자 관점. 이 값이 대상 사용자와 다르면(비로그인 포함) **타인 관점**이므로
// 비밀글·미발행글을 내보내지 않는다.
//
// 종전에는 `is_public` 옵트인 플래그로 이 판정을 했는데, 그 키를 설정하는 코드가
// 저장소 어디에도 없어서 필터가 한 번도 적용되지 않았다(사문). 옵트인은 호출부가
// 빠뜨리면 조용히 열리는 방향이라, 열람자 신원으로 판정하는 fail-closed 로 뒤집는다 —
// viewer_id 가 없으면 자동으로 가장 좁은 가시성이 된다.
$viewerId = $filters['viewer_id'] ?? null;
$isOwnView = $viewerId !== null && $viewerId === $userId;
$excludeBoardSlugs = $filters['exclude_board_slugs'] ?? [];
// board_slug 필터용 board_id 조회
$boardIdFilter = null;
if ($boardSlugFilter) {
$boardIdFilter = Board::where('slug', $boardSlugFilter)->value('id');
}
// exclude_board_slugs → board_id 목록으로 변환
$excludeBoardIds = [];
if (! empty($excludeBoardSlugs)) {
$excludeBoardIds = Board::whereIn('slug', $excludeBoardSlugs)->pluck('id')->all();
}
// DB 레벨 정렬 컬럼 결정
$orderColumn = match ($sort) {
'views' => 'board_posts.view_count',
'oldest' => 'board_posts.created_at',
default => 'board_posts.created_at',
};
$orderDirection = $sort === 'oldest' ? 'asc' : 'desc';
$cachedTotal = $filters['cached_total'] ?? null;
// 분기 선택은 activity_type 만 본다. 가시성은 각 분기 안에서 처리한다 —
// 여기서 열람자까지 보고 분기를 바꾸면 `commented` 요청이 조용히 `authored` 결과를
// 돌려주게 되어 저장소 계약이 깨진다.
if ($activityType === 'commented') {
return $this->getUserCommentedActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $orderColumn, $orderDirection, $perPage, $cachedTotal, $viewerId);
}
// authored (기본값, 공개 프로필 포함)
return $this->getUserAuthoredActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $isOwnView, $orderColumn, $orderDirection, $perPage, $cachedTotal);
}
/**
* 사용자가 작성한 게시글 활동을 DB 레벨 페이지네이션으로 조회합니다.
*
* @param int $userId 사용자 ID
* @param int|null $boardIdFilter 게시판 ID 필터
* @param string|null $search 검색 키워드
* @param bool $isOwnView 열람자가 대상 본인인지 여부 (아니면 비밀글·미발행글 제외)
* @param string $orderColumn 정렬 컬럼
* @param string $orderDirection 정렬 방향
* @param int $perPage 페이지당 항목 수
*/
private function getUserAuthoredActivities(
int $userId,
?int $boardIdFilter,
array $excludeBoardIds,
?string $search,
bool $isOwnView,
string $orderColumn,
string $orderDirection,
int $perPage,
?int $cachedTotal = null
): LengthAwarePaginator {
// JOIN 대신 whereNotIn으로 비활성 게시판 제외 — idx_board_posts_user_created 인덱스 활용
$inactiveBoardIds = $this->getInactiveBoardIds();
$allExcludeIds = array_unique(array_merge($excludeBoardIds, $inactiveBoardIds));
// 관계/정렬은 지연 조인이 담당한다 (inner 는 키 컬럼만 조회)
$query = Post::query()
->where('board_posts.user_id', $userId);
if ($boardIdFilter) {
$query->where('board_posts.board_id', $boardIdFilter);
}
if (! empty($allExcludeIds)) {
$query->whereNotIn('board_posts.board_id', $allExcludeIds);
}
if ($search) {
$keyword = $this->escapeLikeKeyword($search);
$query->where(function ($q) use ($keyword) {
$q->where('board_posts.title', 'like', "%{$keyword}%")
->orWhere('board_posts.content', 'like', "%{$keyword}%");
});
}
// 본문(content)은 목록 가공에 앞부분만 쓰이지만 태그 제거 후 길이가 줄어드는 것을 감안해
// 넉넉히 잘라 읽는다. 잘라 읽기는 이번 페이지 분량(outer)에서만 일어난다.
$listColumns = [
'board_posts.id', 'board_posts.board_id', 'board_posts.user_id',
'board_posts.title', 'board_posts.status', 'board_posts.is_secret',
'board_posts.content_mode', 'board_posts.view_count', 'board_posts.comments_count',
'board_posts.created_at', 'board_posts.updated_at', 'board_posts.deleted_at',
// DB::raw 는 테이블 프리픽스가 적용되지 않는다. 단일 테이블 조회라 컬럼만 적으면 충분하다.
DB::raw('SUBSTRING(content, 1, 1000) as content'),
];
// 캐시된 total 을 그대로 통과시켜 COUNT 를 다시 수행하지 않는다
$paginator = $this->paginateWithDeferredJoin(
query: $query,
columns: $listColumns,
sort: [['column' => $orderColumn, 'direction' => $orderDirection]],
perPage: $perPage,
relations: ['board'],
total: $cachedTotal,
);
// paginate 후 10건에만 PHP 가공 적용 (N+1 아님)
$paginator->through(function ($post) use ($isOwnView) {
// 이 목록은 본문 일부(content_plain)를 함께 싣는다. 타인이 볼 때 비밀글·블라인드
// 글의 본문이 그대로 나가던 것이 결함이었다 — 행과 제목은 게시판 목록에서 이미
// 같은 수준으로 보이므로(PostResource 의 목록 규칙: 제목은 노출, 본문만 차단)
// 여기서도 **행은 남기고 본문만** 비운다. 행을 지우면 프로필의 비밀글/블라인드
// 배지가 사문이 되어 필요 이상으로 기능이 깎인다.
$hideContent = ! $isOwnView
&& ((bool) $post->is_secret || $post->status === PostStatus::Blinded);
return [
'id' => $post->id,
'board_slug' => $post->board?->slug,
'board_name' => $post->board?->getLocalizedName() ?? '',
'activity_type' => 'authored',
'activity_count' => 0,
'title' => $post->title,
'is_secret' => (bool) $post->is_secret,
'status' => $post->status?->value,
'view_count' => $post->view_count,
'comment_count' => (int) ($post->comments_count ?? 0),
'created_at' => $this->formatCreatedAt($post->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
'content_plain' => $hideContent
? ''
: (($post->content_mode ?? 'text') === 'html'
? $this->stripHtmlToPlainText($post->content ?? '')
: ($post->content ?? '')),
];
});
return $paginator;
}
/**
* 사용자가 댓글을 단 게시글 활동을 DB 레벨 페이지네이션으로 조회합니다.
*
* @param int $userId 사용자 ID
* @param int|null $boardIdFilter 게시판 ID 필터
* @param array $excludeBoardIds 제외할 게시판 ID 목록
* @param string|null $search 검색 키워드
* @param string $orderColumn 정렬 컬럼
* @param string $orderDirection 정렬 방향
* @param int $perPage 페이지당 항목 수
*/
private function getUserCommentedActivities(
int $userId,
?int $boardIdFilter,
array $excludeBoardIds,
?string $search,
string $orderColumn,
string $orderDirection,
int $perPage,
?int $cachedTotal = null,
?int $viewerId = null
): LengthAwarePaginator {
// 테이블명은 모델에서 얻는다 — 문자열로 박으면 테이블명이 바뀔 때 조용히 깨진다
$postsTable = (new Post)->getTable();
$commentsTable = (new Comment)->getTable();
// 비활성 게시판 제외 — JOIN 없이 인덱스 활용
$inactiveBoardIds = $this->getInactiveBoardIds();
$allExcludeIds = array_unique(array_merge($excludeBoardIds, $inactiveBoardIds));
/**
* 내가 이 글에 단 댓글 조건 (검색어까지 반영 — 활동 판정 · 건수 집계 공통).
*
* @param \Illuminate\Contracts\Database\Eloquent\Builder $q 댓글 하위 쿼리
*/
$myComment = function ($q) use ($userId, $search) {
$q->where('user_id', $userId)->whereNull('deleted_at');
if ($search) {
$q->where('content', 'like', '%'.$this->escapeLikeKeyword($search).'%');
}
};
// "내가 댓글을 단 글" 집합은 EXISTS 로 정한다. 조인으로 행을 불린 뒤 groupBy 로 접는
// 방식은 inner 가 읽는 행 수를 댓글 수만큼 늘리고, 그룹 쿼리라 총 건수도 서브쿼리로
// 감싸야 한다. EXISTS 는 글 1건당 1행이라 둘 다 필요 없다.
$query = Post::query()->whereHas('comments', $myComment);
if ($boardIdFilter) {
$query->where("{$postsTable}.board_id", $boardIdFilter);
}
if (! empty($allExcludeIds)) {
$query->whereNotIn("{$postsTable}.board_id", $allExcludeIds);
}
// 목록 컬럼: 본문 전체 대신 앞부분만 (가공 후 표시 자수보다 넉넉히 잘라 읽는다).
// SUBSTRING 은 빌더에 대응 표현이 없는 표준 SQL 함수라 이 한 곳만 raw 로 남긴다.
// 테이블명·프리픽스는 문자열로 박지 않고 모델과 연결 설정에서 얻는다.
$listColumns = [
"{$postsTable}.id", "{$postsTable}.board_id", "{$postsTable}.user_id",
"{$postsTable}.title", "{$postsTable}.status", "{$postsTable}.is_secret",
"{$postsTable}.content_mode", "{$postsTable}.view_count", "{$postsTable}.comments_count",
"{$postsTable}.created_at", "{$postsTable}.updated_at", "{$postsTable}.deleted_at",
DB::raw('SUBSTRING('.DB::getTablePrefix().$postsTable.'.content, 1, 1000) as content'),
'lc.content as comment_content',
'lc.created_at as comment_created_at',
];
// 정렬은 항상 board_posts 컬럼(작성일/조회수)이라 inner 에서 그대로 적용된다.
// 캐시된 total 이 있으면 COUNT 를 건너뛴다. 검색/게시판 필터가 걸린 조회는 캐시를 쓰지
// 않아 total 이 null 로 들어오며, 이때 trait 이 그룹 쿼리를 서브쿼리로 감싸 그룹 수를 센다.
$paginator = $this->paginateWithDeferredJoin(
query: $query,
columns: $listColumns,
sort: [['column' => $orderColumn, 'direction' => $orderDirection]],
perPage: $perPage,
relations: ['board'],
keyName: 'id',
total: $cachedTotal,
// 최근 댓글 조회와 활동 건수 집계는 이번 페이지의 게시글에 대해서만 실행한다.
// inner 에 두면 건너뛸 행 전체에 대해 상관 서브쿼리가 돌아간다.
outerUsing: function ($outer) use ($userId, $myComment, $postsTable, $commentsTable) {
// 글마다 "내가 단 댓글 중 가장 최근 1건" — 원래 raw SQL 로 쓰던 상관 서브쿼리를
// 빌더로 옮겼다. created_at 동률에서 순서가 흔들리지 않도록 id 를 덧붙인다.
$latest = DB::table("{$commentsTable} as bc_outer")
->select('bc_outer.post_id', 'bc_outer.content', 'bc_outer.created_at')
->where('bc_outer.user_id', $userId)
->whereNull('bc_outer.deleted_at')
->where('bc_outer.id', '=', function ($q) use ($userId, $commentsTable) {
$q->select('bc2.id')
->from("{$commentsTable} as bc2")
->whereColumn('bc2.post_id', 'bc_outer.post_id')
->where('bc2.user_id', $userId)
->whereNull('bc2.deleted_at')
->orderByDesc('bc2.created_at')
->orderByDesc('bc2.id')
->limit(1);
});
$outer->leftJoinSub($latest, 'lc', "{$postsTable}.id", '=', 'lc.post_id')
->withCount(['comments as activity_count' => $myComment]);
},
);
// paginate 후 10건에만 PHP 가공 적용
$paginator->through(function ($post) use ($viewerId) {
// 이 목록은 "내가 댓글 단 글" 이라 **타인이 쓴 비밀글**이 섞인다. 행은 내 활동
// 기록이므로 남기되 본문은 내보내지 않는다 — 목록 미리보기를 빈 문자열로 만드는
// PostResource::getMaskedContentPreviewForList 와 같은 규칙이다.
// (블라인드 글도 동일: 상세·목록 어느 경로에서도 본문이 나가지 않는다.)
// 열람자 미상($viewerId === null)이면 비밀글은 전부 가린다(fail-closed).
$hideContent = ((bool) $post->is_secret && (int) $post->user_id !== $viewerId)
|| $post->status === PostStatus::Blinded;
return [
'id' => $post->id,
'board_slug' => $post->board?->slug,
'board_name' => $post->board?->getLocalizedName() ?? '',
'activity_type' => 'commented',
'activity_count' => (int) ($post->activity_count ?? 0),
'title' => $post->title,
'is_secret' => (bool) $post->is_secret,
'status' => $post->status?->value,
'view_count' => $post->view_count,
'comment_count' => (int) ($post->comments_count ?? 0),
'created_at' => $this->formatCreatedAt($post->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
'content_plain' => $hideContent
? ''
: (($post->content_mode ?? 'text') === 'html'
? $this->stripHtmlToPlainText($post->content ?? '')
: ($post->content ?? '')),
];
});
return $paginator;
}
/**
* 게시판에서 키워드로 게시글을 검색합니다.
*
* 공개 게시글(published, 비밀글 제외)만 대상으로 제목/본문 LIKE 검색을 수행합니다.
*
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchByKeyword(
string $slug,
string $keyword,
string $orderBy = 'created_at',
string $direction = 'desc',
int $perPage = 10,
int $page = 1
): BoundedPage {
// 종전에는 같은 FULLTEXT 술어를 count() 로 한 번, get() 으로 또 한 번 실행했다.
// 페이지네이터 한 번으로 합치고, 총 건수에는 상한을 건다.
$query = $this->buildPublicSearchQuery($slug, $keyword)
->with('user')
->orderBy($orderBy, $direction)
// 전순서 보장 — 정렬 컬럼이 비고유라 페이지 경계에서 행이 겹치거나 샐 수 있다
->orderBy('id', $direction === 'asc' ? 'asc' : 'desc');
return BoundedPaginator::paginate(
$query,
perPage: $perPage,
page: $page,
resultCap: PaginationLimits::resultCap('search'),
);
}
/**
* 게시판에서 키워드와 일치하는 게시글 수를 조회합니다.
*
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @return BoundedCount 일치하는 게시글 수 (정확도 포함)
*/
public function countByKeyword(string $slug, string $keyword): BoundedCount
{
return BoundedPaginator::count(
$this->buildPublicSearchQuery($slug, $keyword),
PaginationLimits::resultCap('search')
);
}
/**
* 여러 게시판에서 키워드로 게시글을 검색합니다 (단일 쿼리, DB 페이지네이션).
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchAcrossBoards(
array $boardIds,
string $keyword,
string $orderBy = 'created_at',
string $direction = 'desc',
int $perPage = 10,
int $page = 1
): BoundedPage {
// count() + get() 이중 실행을 페이지네이터 한 번으로 합친다.
// 다른 탭 조회 시 배지용 COUNT 까지 더해 같은 술어가 3회 실행되던 경로다.
$query = $this->buildPublicSearchQueryByIds($boardIds, $keyword)
->with('user', 'board')
->orderBy($orderBy, $direction)
->orderBy('id', $direction === 'asc' ? 'asc' : 'desc');
return BoundedPaginator::paginate(
$query,
perPage: $perPage,
page: $page,
resultCap: PaginationLimits::resultCap('search'),
);
}
/**
* {@inheritDoc}
*/
public function searchAcrossBoardsByCursor(
array $boardIds,
string $keyword,
array $sortKeys,
int $perPage = 10,
?string $cursor = null
): CursorPaginator {
// 커서 모드에는 OFFSET 이 없다. 깊은 페이지에서 건너뛸 행을 실제로 읽던 비용이
// 사라지므로 상한 COUNT 도 이 경로에서는 하지 않는다 (총 건수는 배지 집계 담당).
$query = $this->buildPublicSearchQueryByIds($boardIds, $keyword)
->with('user', 'board');
return KeysetPaginator::paginate(
query: $query,
perPage: $perPage,
sortKeys: $sortKeys,
uniqueKey: 'id',
cursor: $cursor,
);
}
/**
* 여러 게시판에서 키워드와 일치하는 게시글 수를 조회합니다 (단일 쿼리).
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @return BoundedCount 키워드와 일치하는 게시글 수 (정확도 포함)
*/
public function countAcrossBoards(array $boardIds, string $keyword): BoundedCount
{
return BoundedPaginator::count(
$this->buildPublicSearchQueryByIds($boardIds, $keyword),
PaginationLimits::resultCap('search')
);
}
/**
* 공개 게시글 검색용 기본 쿼리를 생성합니다.
*
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @return Builder
*/
private function buildPublicSearchQuery(string $slug, string $keyword)
{
$board = Board::where('slug', $slug)->first();
$query = Post::query()
->where('board_id', $board?->id)
->where('status', PostStatus::Published->value)
->where('is_secret', false);
$this->applyKeywordSearch($query, $keyword);
return $query;
}
/**
* 여러 게시판 ID를 대상으로 공개 게시글 검색용 기본 쿼리를 생성합니다.
*
* @param array $boardIds 게시판 ID 목록
* @param string $keyword 검색 키워드
* @return Builder
*/
private function buildPublicSearchQueryByIds(array $boardIds, string $keyword)
{
$query = Post::query()
->whereIn('board_id', $boardIds)
->where('status', PostStatus::Published->value)
->where('is_secret', false);
$this->applyKeywordSearch($query, $keyword);
return $query;
}
/**
* 키워드 검색 조건을 쿼리에 적용합니다.
*
* 어떤 조건이 붙는지는 활성 검색 엔진이 정합니다. 저장소는 "이 컬럼들로 이 키워드를
* 걸어라" 만 말하고 엔진 종류를 알지 않습니다 — 구체 엔진을 여기서 지목하면 플러그인이
* 등록한 검색 엔진이 호출될 기회 자체를 잃습니다. 정제·폴백(LIKE, 와일드카드 escape
* 포함)은 모두 코어 해석기가 단독으로 수행합니다.
*
* @param Builder $query 쿼리 빌더
* @param string $keyword 검색 키워드
*/
private function applyKeywordSearch(Builder $query, string $keyword): void
{
KeywordSearch::apply($query, ['title', 'content'], $keyword);
}
/**
* LIKE 쿼리용 키워드를 이스케이프합니다.
* MySQL LIKE 와일드카드 문자(%, _)를 이스케이프하여 특수문자 검색을 가능하게 합니다.
*
* @param string $keyword 검색 키워드
* @return string 이스케이프된 키워드
*/
private function escapeLikeKeyword(string $keyword): string
{
// MySQL LIKE 와일드카드 문자 이스케이프
$keyword = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
return $keyword;
}
/**
* HTML 콘텐츠를 일반 텍스트로 변환합니다.
* 블록 요소(p, div, br, li 등)는 공백으로 치환하여 자연스러운 줄바꿈을 유지합니다.
*
* @param string $html HTML 콘텐츠
* @return string 일반 텍스트
*/
private function stripHtmlToPlainText(string $html): string
{
// HTML 엔티티 디코딩
$text = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
// 블록 요소 태그를 공백으로 치환 (줄바꿈 효과)
$text = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', ' ', $text);
$text = preg_replace('/<br\s*\/?>/i', ' ', $text);
// 나머지 HTML 태그 제거
$text = strip_tags($text);
// 연속된 공백을 하나로 정리
$text = preg_replace('/\s+/', ' ', $text);
// 앞뒤 공백 제거
return trim($text);
}
/**
* 게시판 ID와 게시글 ID로 게시글을 조회합니다 (삭제 포함).
*
* @param int $boardId 게시판 ID
* @param int $id 게시글 ID
* @return Post|null 게시글 또는 null
*/
public function findByBoardId(int $boardId, int $id): ?Post
{
return Post::query()
->where('board_id', $boardId)
->withTrashed()
->with(['user'])
->find($id);
}
/**
* Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다.
*
* lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
* 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
*
* @param int $boardId 게시판 ID
* @param int $chunkSize 청크 크기
* @return iterable<Post> 공개 게시글 순회자 (id, updated_at 만 조회)
*/
public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable
{
return Post::query()
->where('board_id', $boardId)
->where('status', PostStatus::Published)
->where('is_secret', false)
->select(['id', 'updated_at'])
->orderBy('id')
->lazyById($chunkSize);
}
/**
* 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다.
*
* @param int $boardId 게시판 ID
* @return int 삭제된 게시글 수
*/
public function softDeleteByBoardId(int $boardId): int
{
return Post::where('board_id', $boardId)->delete();
}
/**
* 게시판 ID 기준으로 게시글을 일괄 영구 삭제합니다.
*
* 게시판 영구 삭제(deleteBoard) 시 사용합니다. 소프트 삭제와 달리
* deleted_at 마킹이 아니라 레코드를 물리적으로 제거합니다.
*
* @param int $boardId 게시판 ID
* @return int 삭제된 게시글 수
*/
public function forceDeleteByBoardId(int $boardId): int
{
return Post::where('board_id', $boardId)->forceDelete();
}
/**
* Eager loading 관계에 board_id 조건을 명시적으로 바인딩합니다.
*
* 모델 관계 정의에서 $this->board_id를 사용하면 Eager loading 시
* null이 되는 문제를 해결하기 위해, 문자열 관계명을 클로저로 변환합니다.
*
* @param array $relations Eager loading 관계 배열
* @param int|null $boardId 게시판 ID
* @return array board_id 조건이 바인딩된 관계 배열
*/
private function bindBoardIdToRelations(array $relations, ?int $boardId): array
{
$boardIdRelations = ['comments', 'attachments', 'replies'];
$result = [];
foreach ($relations as $key => $value) {
// 클로저가 이미 있는 경우 (예: 'comments' => function() {})는 그대로 유지
if (is_string($key) && is_callable($value)) {
$result[$key] = $value;
continue;
}
// 문자열 관계명인 경우 board_id 조건 클로저로 변환
if (is_string($value) && in_array($value, $boardIdRelations)) {
$result[$value] = function ($query) use ($boardId) {
$query->where('board_id', $boardId);
};
continue;
}
// 그 외 (예: 'user')는 그대로 유지
$result[$key] = $value;
}
return $result;
}
/**
* ID로 게시글을 조회합니다 (게시판 슬러그 불필요, board 관계 포함).
*
* @param int $id 게시글 ID
* @return Post|null 게시글 모델 (board 관계 포함) 또는 null
*/
public function findWithBoard(int $id): ?Post
{
return Post::with('board')->find($id);
}
/**
* ID 배열로 게시글 목록을 조회합니다 (board, user, attachments, replies 관계 포함).
*
* @param array<int> $ids 게시글 ID 배열
* @return \Illuminate\Database\Eloquent\Collection<int, Post> 게시글 컬렉션
*/
public function findByIdsWithRelations(array $ids): \Illuminate\Database\Eloquent\Collection
{
return Post::whereIn('id', $ids)
->with(['board', 'user', 'attachments', 'replies'])
->get();
}
/**
* 부모 게시글 ID로 첫 번째 자식(답변) 게시글을 조회합니다 (board 관계 포함).
*
* @param int $parentPostId 부모 게시글 ID
* @return Post|null 첫 번째 자식 게시글 (board 관계 포함) 또는 null
*/
public function findFirstReplyWithBoard(int $parentPostId): ?Post
{
return Post::with('board')
->where('parent_id', $parentPostId)
->oldest()
->first();
}
/**
* 비활성 게시판 ID 목록을 조회합니다.
*
* boards 테이블은 소규모(~수십 건)이므로 단순 쿼리로 충분합니다.
* JOIN 대신 whereNotIn 패턴에 사용하여 board_posts 인덱스 활용을 유도합니다.
*
* @return array<int> 비활성 게시판 ID 배열
*/
private function getInactiveBoardIds(): array
{
// audit:allow query-unbounded-get reason: 게시판은 운영자가 만든 수만큼만 존재한다 (글 수와 무관)
return Board::where('is_active', false)->pluck('id')->all();
}
/**
* 게시글의 comments_count 컬럼을 활성 댓글 수로 재계산해 갱신합니다.
*
* @param int $postId 게시글 ID
* @return int 갱신된 카운트 값
*/
public function recalculateCommentsCount(int $postId): int
{
$count = Comment::where('post_id', $postId)->whereNull('deleted_at')->count();
Post::where('id', $postId)->update(['comments_count' => $count]);
return $count;
}
/**
* 게시글의 attachments_count 컬럼을 활성 첨부파일 수로 재계산해 갱신합니다.
*
* @param int $postId 게시글 ID
* @return int 갱신된 카운트 값
*/
public function recalculateAttachmentsCount(int $postId): int
{
$count = Attachment::where('post_id', $postId)->whereNull('deleted_at')->count();
Post::where('id', $postId)->update(['attachments_count' => $count]);
return $count;
}
/**
* 부모 게시글의 replies_count 컬럼을 활성 답글 수로 재계산해 갱신합니다.
*
* @param int $parentPostId 부모 게시글 ID
* @return int 갱신된 카운트 값
*/
public function recalculateRepliesCount(int $parentPostId): int
{
$count = Post::where('parent_id', $parentPostId)->whereNull('deleted_at')->count();
Post::where('id', $parentPostId)->update(['replies_count' => $count]);
return $count;
}
/**
* 특정 날짜에 작성된 전체 게시판의 게시글 수를 조회합니다 (대시보드 집계용).
*
* @param string $date 집계 기준 날짜 (Y-m-d)
* @return int 해당 날짜 작성 게시글 수
*/
public function countCreatedOnDate(string $date): int
{
$start = CarbonImmutable::parse($date)->startOfDay();
$end = $start->addDay();
return Post::query()
->whereNull('deleted_at')
->where('created_at', '>=', $start)
->where('created_at', '<', $end)
->count();
}
/**
* 전체 게시판에서 최신 게시글을 조회합니다 (대시보드 최신글 카드용).
*
* 답글(parent_id != null)은 본문 게시글이 아니므로 제외한다.
* 다른 메서드(검색/인기/카테고리 목록 등)와 동일한 "원글만" 컨벤션 유지.
*
* @param int $limit 조회 건수
* @return \Illuminate\Database\Eloquent\Collection<int, Post> 최신 게시글 컬렉션
*/
public function getRecentAcrossBoards(int $limit): \Illuminate\Database\Eloquent\Collection
{
return Post::query()
->whereNull('deleted_at')
->whereNull('parent_id')
// 노출 제한 필터 — 미발행(블라인드·삭제)·비활성 게시판 글은 대시보드 최신글에서 제외한다.
// 비밀글은 제목 공개 정책(2026-01-02)에 따라 관리자에게 제목을 노출한다(제외하지 않음).
->where('status', PostStatus::Published->value)
->whereHas('board', fn ($q) => $q->where('is_active', true))
->with(['board', 'user'])
->orderByDesc('created_at')
->limit($limit)
->get();
}
}