perf(board): 목록·검색·상세의 중복 실행 제거 및 상한 계약 소비

검색 — 이 코어에 추가한 BOOLEAN MODE 정제기가 게시판에는 도달하지 않아
원문 키워드가 그대로 바인딩되고 있었다. + - * " < > 를 넣으면 오류 화면이 떴다.
코어 정제기 경유로 옮겼고, 같은 술어를 count 와 get 으로 두 번 실행하던 것을
페이지네이터 한 번으로 합쳤다. 구체 엔진 직접 호출도 해석기 경유로 바꿔 플러그인
검색엔진이 게시글·신고 검색에 적용된다.

상세 — 같은 글을 3회, 같은 게시판을 5회 읽고 있었다. 조회한 인스턴스를 넘겨
글 1회·게시판 1회로 줄였고, 조회수 증가를 권한 확인 뒤로 옮겨 열람 권한이 없는
요청이 조회수를 올리지 않게 했다.

목록 — 답변 트리의 무제한 조회에 상한과 순환 가드를 두고, O(n²) 병합을 한 패스로
바꿨다. 댓글도 무제한 조회에 상한을 두되 잘렸다는 사실과 전체 건수를 함께 알린다.
조용히 잘리면 "댓글이 그만큼뿐" 으로 보인다. 원댓글 단위 페이지네이션을 더해
답글이 부모와 떨어지지 않게 했다.

관리자 화면 — 총 건수가 잘린 목록에서 페이저가 "1페이지뿐" 으로 접히던 자리를
고쳤다. 저장소가 상한을 붙인 목록인데 화면이 그 값을 1 로 채우고 있어, 기록이
상한을 넘으면 2페이지에 도달할 방법이 없었다.

같은 파일에서 드러난 사전 결함도 함께 처리했다 — 반복 렌더 안의 정적 HTML id,
Icon 의 박스 크기 지정, 단색 버튼의 다크 모드 대비.
This commit is contained in:
HeuJung
2026-08-06 11:13:51 +09:00
parent 2ae1972a3b
commit 41fe812f2c
31 changed files with 2092 additions and 372 deletions
@@ -19,18 +19,36 @@
### Changed
- 통합 검색의 게시글 결과를 최신순·오래된순·조회순으로 볼 때 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 관련도순은 종전의 페이지 번호 방식을 유지합니다.
- 플러그인으로 다른 검색엔진을 연결하면 게시글·신고 검색에도 그 엔진이 적용됩니다. 이전에는 연결한 엔진과 무관하게 기본 방식으로만 검색됐습니다.
- 게시글을 열 때 같은 글과 같은 게시판을 여러 번 다시 읽던 것을 정리했습니다. 글은 한 번만, 게시판도 한 번만 읽습니다. 화면에 보이는 내용은 동일하며 글이 열리는 속도가 빨라집니다. 조회수는 열람 권한을 확인한 뒤에만 올라갑니다.
- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 게시글 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 게시글의 본문 앞부분까지 함께 읽어 느려졌지만, 이제 현재 페이지의 게시글만 본문 미리보기를 읽습니다. 공지 노출·답글 표시·다음 페이지 버튼은 이전과 동일하게 동작합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 신고 관리 목록도 같은 방식으로 개선했습니다. 신고 건수·대상 글 상태처럼 목록에 함께 표시되는 정보를 예전에는 건너뛰는 신고까지 모두 계산했지만, 이제 현재 페이지의 신고에 대해서만 계산합니다. 신고가 많이 쌓인 사이트에서 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 마이페이지의 「내가 쓴 댓글」·「내가 댓글 단 글」 목록도 같은 방식으로 개선했습니다. 각 글의 최근 댓글을 예전에는 건너뛰는 글까지 모두 찾아봤지만, 이제 현재 페이지의 글에 대해서만 찾습니다. 댓글 활동이 많은 회원의 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글이 많은 게시판에서 목록 정렬이 더 빨라지도록 색인을 정비했습니다. 신고 관리 목록에도 같은 정비를 적용했습니다. 같은 시각에 등록된 게시글이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 게시글이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 글쓰기가 잠시 대기할 수 있습니다.
- 관리자 게시판 목록이 게시판마다 매니저·스텝 역할과 그 역할에 속한 회원 명단(이름·이메일)을 함께 내려주지 않습니다. 목록 화면이 쓰지 않는 값이라 게시판 수만큼 늘어나던 조회와 전송량이 사라집니다. 역할 지정은 종전대로 게시판 상세·설정 화면에서 하며, 목록에 보이는 항목은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 한 글의 댓글이 아주 많아 목록이 일정 수에서 끊긴 경우, 끊겼다는 사실과 전체 건수를 함께 알려 줍니다. 예전에는 조용히 잘려 "댓글이 그만큼뿐" 으로 보였습니다.
- 댓글이 아주 많은 글에서도 뒤쪽 댓글을 볼 수 있도록 댓글 목록을 나눠 받을 수 있게 했습니다. 페이지 단위는 원댓글이며, 그 원댓글에 달린 답글은 함께 따라오므로 답글이 부모와 떨어지지 않습니다. 기존처럼 한 번에 받는 방식도 그대로 동작합니다.
- 게시글 검색 탭의 배지 숫자도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 배지를 표시하지 않는 화면에서는 그 숫자를 세는 작업 자체를 건너뛸 수 있습니다.
- 게시글 목록의 총 건수도 일정 규모까지만 정확히 세고 그보다 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다.
- 신고 관리 목록의 총 건수에도 같은 방식을 적용했습니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
- 게시판 검색이 조건에 맞는 글을 두 번 조회하던 것을 한 번으로 합쳤습니다. 글이 많은 게시판일수록 검색이 빨라집니다.
- 검색 결과가 아주 많으면 총 건수를 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다.
- 관리자 게시판 목록이 삭제글을 포함해 열릴 때마다 전체 건수를 다시 세던 것을 저장해 두고 재사용하도록 바꿨습니다.
- 답변글이 아주 많은 글을 열 때 답변 전체를 한꺼번에 읽던 것에 상한을 두고, 답변을 트리로 묶는 처리도 개선했습니다.
- 한 글의 댓글을 무제한으로 읽던 것에 상한을 두고, 같은 댓글을 두 번 가져오던 부분을 없앴습니다.
### Fixed
- 게시판 알림 설정 화면에서 모든 알림의 제목·수신자·활성 여부가 비어 보이고 「이 채널에 대한 템플릿이 없습니다」로만 표시되던 문제를 수정했습니다. 편집 창을 열어도 내용이 채워지지 않았습니다. 채널을 전환하거나 새로고침해도 마찬가지였습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 게시판 설정의 '새 글 표시 시간'이 저장 직후에는 숫자가 아닌 형태로 다뤄져, 조회 시점에 따라 값의 형태가 달라지던 문제를 수정했습니다. 새 글 표시 여부 판정에 쓰이는 값이므로 항상 숫자로 처리합니다.
- 게시글이 아주 많은 게시판에서 목록의 글 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 총 건수를 끝까지 세지 못하는 경우에는 번호를 지어내지 않고 「-」로 표시합니다.
- 통합 검색 결과에서 작성자 자리에 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다. 이제 「비회원」으로 표시됩니다.
- 통합 검색을 최신순으로 볼 때 첫 페이지부터 이어보기 방식이 적용되도록 수정했습니다. 이전에는 뒤쪽 페이지로 갈수록 느려지는 방식이 계속 쓰였습니다. 주소로 특정 페이지를 열어 둔 링크는 종전대로 그 페이지를 보여 줍니다.
- 게시판 목록에서 총 건수를 끝까지 세지 못했을 때 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다.
- 마이페이지의 내가 쓴 글·내가 쓴 댓글 목록에서 총 건수를 끝까지 세지 못했을 때 페이지 이동 막대가 사라지던 문제를 수정했습니다.
- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -68,6 +86,9 @@
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
- 게시판 모듈을 제거할 때 게시판별로 자동 생성된 관리자·승인 단계 역할이 1,000개를 넘으면 일부가 삭제되지 않고 남던 문제를 수정했습니다. 남은 역할은 사용하지 않는 게시판의 것이면서도 회원 권한 목록에 계속 노출됐습니다. 이제 개수와 무관하게 전부 정리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
- 검색어에 `+` `-` `*` `"` `<` `>` 같은 기호를 넣으면 게시판 검색이 오류 화면을 띄우던 문제를 수정했습니다. 이제 어떤 문자를 입력해도 검색이 정상 동작합니다.
- 게시글 상세를 열 때 같은 글과 게시판을 여러 번 다시 조회하던 것을 정리했습니다.
- 관리자 게시판 목록·신고 관리 목록과 신고 상세의 신고자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 페이지 이동이 막히던 문제를 수정했습니다. 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
## [1.0.2] - 2026-07-14
@@ -16,6 +16,22 @@
---
## 목록·검색의 총 건수와 답변·댓글 상한
게시판 목록에 `search` 를 얹으면 내부 검색이 수행됩니다. 매칭이 아주 많을 수 있으므로 총
건수는 상한까지만 세며, 상한을 넘으면 응답의 `pagination` 에 정확도가 함께 실립니다
(`total_relation` / `total_is_exact` / `result_cap`). 이때 `last_page` 는 `null` 이고
`has_more_pages` 는 그대로 정확하므로, 마지막 페이지 점프만 감춰지고 다음 페이지 이동은
끝까지 열려 있습니다. 상세 규약은 [pagination.md](../../../../../docs/backend/pagination.md) 를 참고하세요.
검색어에 `+` `-` `*` `"` `<` `>` 같은 문자가 들어와도 오류가 나지 않습니다. 코어 정제기가
FULLTEXT 연산자를 제거한 뒤 검색하며, 연산자만 입력한 경우에는 오류 대신 빈 결과를 돌려줍니다.
게시글 상세 응답의 답변 트리와 댓글 목록에도 같은 상한이 적용됩니다. 한 글에 답변·댓글이
극단적으로 많은 경우 그 지점에서 끊기며, 총 건수는 목록 응답의 집계로 확인할 수 있습니다.
---
### POST /api/modules/sirsoft-board/admin/board/{slug}/attachments
<!-- @generated:start:api.modules.sirsoft-board.admin.board.attachments.upload -->
@@ -864,6 +880,9 @@ _단건 응답: `data` 객체의 필드._
| navigation | object | `{"prev":null,"next":null}` | 이전/다음 게시글 이동 정보. `prev`·`next` 키에 인접 게시글 요약(없으면 null)이 담기며, 상세 로드 시 함께 계산됩니다. |
| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) |
| comments | array | `[{"id":760,"post_id":237,"parent_id":null,"content":"API …` | 게시글에 달린 댓글 목록(CommentResource 컬렉션). comments 관계가 로드된 경우에만 채워지며, 각 항목에 신고 여부가 사전 로드되어 담깁니다. |
| comments_truncated | boolean | `false` | 댓글 목록이 상한에서 끊겼는지 여부. `true` 면 `comments` 에 실린 것이 전부가 아닙니다 |
| comments_total | integer\|null | `12` | 댓글 총 건수. 끊기지 않았으면 `comments` 길이와 같고, 끊겼으면 상한값(그 이상)입니다 |
| comments_total_is_exact | boolean | `true` | 위 총 건수가 정확한지 여부. `false` 면 "N건 이상" 으로 표기합니다 |
| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apid…` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). 비밀글은 열람 권한이 없으면 빈 배열, 삭제된 게시글은 관리 권한이 없으면 연쇄 삭제된 첨부만 노출됩니다. |
| replies | array | `[]` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
| is_already_reported | boolean | `false` | already reported 여부 |
@@ -86,7 +86,7 @@
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-5 h-5"
"className": "text-xl"
}
}
]
@@ -116,7 +116,7 @@
"name": "Icon",
"props": {
"name": "plus",
"className": "w-5 h-5"
"className": "text-xl"
}
},
{
@@ -183,7 +183,8 @@
"pagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{boards?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{boards?.data?.pagination?.last_page ?? 1}}",
"serverTotalPages": "{{boards?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{boards?.data?.pagination?.has_more_pages ?? false}}",
"alwaysShowPagination": true,
"emptyMessage": "$t:sirsoft-board.admin.board.index.empty_description",
"cardClassName": "rounded-lg bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4",
@@ -457,12 +458,13 @@
"text": "$t:sirsoft-board.admin.board.index.categories_label"
},
{
"id": "category_tag",
"id": "category_tag_{{categoryIdx}}",
"type": "composite",
"name": "StatusBadge",
"iteration": {
"source": "row.categories",
"item_var": "category"
"item_var": "category",
"index_var": "categoryIdx"
},
"props": {
"status": "default",
@@ -749,7 +751,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white dark:text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
@@ -744,7 +744,7 @@
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded text-xs font-bold bg-blue-600 text-white dark:bg-blue-500"
},
"text": "{{row.number}}",
"text": "{{row.number ?? '-'}}",
"if": "{{row.row_type === 'notice'}}"
},
{
@@ -753,7 +753,7 @@
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
},
"text": "{{row.number}}",
"text": "{{row.number ?? '-'}}",
"if": "{{row.row_type === 'reply'}}"
},
{
@@ -762,7 +762,7 @@
"props": {
"className": "font-medium text-gray-900 dark:text-white"
},
"text": "{{row.number}}",
"text": "{{row.number ?? '-'}}",
"if": "{{row.row_type === 'normal'}}"
}
]
@@ -1003,7 +1003,8 @@
"pageSize": 15,
"serverSidePagination": true,
"serverCurrentPage": "{{posts?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{posts?.data?.pagination?.last_page ?? 1}}",
"serverTotalPages": "{{posts?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{posts?.data?.pagination?.has_more_pages ?? false}}",
"selectable": false,
"responsiveBreakpoint": 768,
"showFirstLast": true,
@@ -2096,7 +2096,8 @@
"pageSize": 20,
"serverSidePagination": true,
"serverCurrentPage": "{{reports?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{reports?.data?.pagination?.last_page ?? 1}}",
"serverTotalPages": "{{reports?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{reports?.data?.pagination?.has_more_pages ?? false}}",
"selectable": true,
"selectedIds": "{{_global.selectedIds || []}}",
"idField": "id",
@@ -81,7 +81,7 @@
"name": "Icon",
"props": {
"name": "chart-bar",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -121,7 +121,7 @@
"name": "Icon",
"props": {
"name": "calendar",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -163,7 +163,7 @@
"name": "Icon",
"props": {
"name": "circle-info",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -336,7 +336,7 @@
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3"
"className": "text-xs"
}
}
]
@@ -410,7 +410,7 @@
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3 transform translate-x-0 group-hover:translate-x-0.5 transition-all"
"className": "text-xs transform translate-x-0 group-hover:translate-x-0.5 transition-all"
}
}
]
@@ -470,7 +470,7 @@
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3 transform translate-x-0 group-hover:translate-x-0.5 transition-all"
"className": "text-xs transform translate-x-0 group-hover:translate-x-0.5 transition-all"
}
}
]
@@ -511,7 +511,7 @@
"name": "Icon",
"props": {
"name": "clock",
"className": "w-4 h-4 text-gray-500 dark:text-gray-400"
"className": "text-base text-gray-500 dark:text-gray-400"
}
},
{
@@ -618,7 +618,7 @@
},
"children": [
{
"id": "reporters_list_container",
"id": "reporters_list_container_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.reporters_loading}}",
@@ -627,11 +627,12 @@
},
"iteration": {
"source": "reporters_list?.data?.data ?? report_detail?.data?.reporters ?? []",
"item_var": "reporter"
"item_var": "reporter",
"index_var": "reporterIdx"
},
"children": [
{
"id": "reporter_item_card",
"id": "reporter_item_card_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"props": {
@@ -639,7 +640,7 @@
},
"children": [
{
"id": "card_inner_grid",
"id": "card_inner_grid_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"props": {
@@ -647,7 +648,7 @@
},
"children": [
{
"id": "reporter_field",
"id": "reporter_field_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"children": [
@@ -713,7 +714,7 @@
]
},
{
"id": "reported_at_field",
"id": "reported_at_field_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"children": [
@@ -736,7 +737,7 @@
]
},
{
"id": "reason_type_field",
"id": "reason_type_field_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"children": [
@@ -759,7 +760,7 @@
]
},
{
"id": "reporter_item_reason",
"id": "reporter_item_reason_{{reporterIdx}}",
"type": "basic",
"name": "Div",
"props": {
@@ -794,10 +795,11 @@
"id": "reporters_pagination",
"type": "composite",
"name": "Pagination",
"if": "{{(reporters_list?.data?.pagination?.last_page ?? 1) > 1}}",
"if": "{{((reporters_list?.data?.pagination?.last_page ?? 0) > 1) || (reporters_list?.data?.pagination?.has_more_pages === true) || ((reporters_list?.data?.pagination?.current_page ?? 1) > 1)}}",
"props": {
"currentPage": "{{reporters_list?.data?.pagination?.current_page ?? 1}}",
"totalPages": "{{reporters_list?.data?.pagination?.last_page ?? 1}}",
"totalPages": "{{reporters_list?.data?.pagination?.last_page ?? null}}",
"hasMorePages": "{{reporters_list?.data?.pagination?.has_more_pages ?? false}}",
"className": "mt-4 justify-end"
},
"actions": [
@@ -1045,7 +1045,7 @@
"props": {
"className": "text-tertiary"
},
"text": "$t:admin.identity.policies.pagination_summary|total={{boardIdentityPolicies?.data?.meta?.total ?? 0}}|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? 1}}"
"text": "$t:admin.identity.policies.pagination_summary|total={{boardIdentityPolicies?.data?.meta?.total ?? 0}}|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? '-'}}"
},
{
"type": "basic",
@@ -1098,14 +1098,14 @@
"props": {
"className": "px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200"
},
"text": "$t:admin.identity.policies.page_indicator|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? 1}}"
"text": "$t:admin.identity.policies.page_indicator|page={{boardIdentityPolicies?.data?.meta?.current_page ?? 1}}|last={{boardIdentityPolicies?.data?.meta?.last_page ?? '-'}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{(boardIdentityPolicies?.data?.meta?.current_page ?? 1) >= (boardIdentityPolicies?.data?.meta?.last_page ?? 1)}}",
"disabled": "{{boardIdentityPolicies?.data?.meta?.last_page ? ((boardIdentityPolicies?.data?.meta?.current_page ?? 1) >= boardIdentityPolicies?.data?.meta?.last_page) : (boardIdentityPolicies?.data?.meta?.has_more_pages !== true)}}",
"className": "px-3 py-1.5 text-xs font-medium bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
@@ -1118,7 +1118,7 @@
"mergeQuery": true,
"query": {
"tab": "identity_policies",
"page": "{{Math.min(boardIdentityPolicies?.data?.meta?.last_page ?? 1, (boardIdentityPolicies?.data?.meta?.current_page ?? 1) + 1)}}"
"page": "{{boardIdentityPolicies?.data?.meta?.last_page ? Math.min(boardIdentityPolicies?.data?.meta?.last_page, (boardIdentityPolicies?.data?.meta?.current_page ?? 1) + 1) : ((boardIdentityPolicies?.data?.meta?.current_page ?? 1) + 1)}}"
},
"transition_overlay_target": "policy_table_card",
"scroll": {
@@ -713,7 +713,8 @@
"name": "Pagination",
"props": {
"currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}",
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}"
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page ?? null}}",
"hasMorePages": "{{boardNotificationDefinitions?.data?.pagination?.has_more_pages ?? false}}"
},
"actions": [
{
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\User;
use App\Enums\PermissionType;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Models\User;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -140,8 +141,10 @@ class PostController extends PublicBaseController
throw new BoardNotFoundException($slug);
}
// 게시글 조회 (스코프 접근 검사 포함)
$post = $this->postService->getPost($slug, $id, context: 'user');
// 게시글 조회 (댓글/첨부파일/답글 관계 + 스코프 접근 검사).
// 권한 판정과 응답 조립이 같은 인스턴스를 공유해 같은 행을 다시 읽지 않는다 (#519 F3).
// 이미 조회한 Board 를 넘겨 게시판 재조회와 board 관계 적재도 함께 생략한다.
$post = $this->postService->getPostWithCounts($slug, $id, board: $board, context: 'user');
// 삭제된 게시글은 manager 권한 필요
if ($post->trashed()) {
@@ -150,21 +153,50 @@ class PostController extends PublicBaseController
}
}
// 조회수 증가 (캐시 기반 중복 방지)
$this->postService->incrementViewCountOnce($slug, $id);
// 댓글/첨부파일/답글 포함하여 게시글 조회 (boardId 전달로 Board 재조회 방지)
$post = $this->postService->getPostWithCounts($slug, $id, $board->id);
// board 관계 수동 설정
$post->setRelation('board', $board);
// 조회수 증가 (캐시 기반 중복 방지). 권한 확인을 통과한 열람만 센다.
// 증가분은 이미 조회한 인스턴스에 반영해 조회수 하나 때문에 글을 다시 읽지 않는다.
if ($this->postService->incrementViewCountOnce($slug, $id, $board->id)) {
$post->view_count = (int) $post->view_count + 1;
}
// manager 권한 체크 (삭제 게시글/댓글 포함 여부 결정)
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
// 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
$withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id);
// comment_page 가 오면 원댓글 기준 페이지네이션 경로를 쓴다. 댓글이 상한을 넘는
// 글에서도 뒤쪽 댓글에 도달할 수 있어야 하기 때문이다. 파라미터가 없으면
// 종전대로 상한까지 전량을 싣는다(기존 화면 응답 형태 불변).
$commentPage = $this->resolveCommentPage($request);
$commentPagination = null;
if ($commentPage !== null) {
$paginated = $this->commentService->paginateCommentsByPostId(
$slug,
$id,
perPage: $commentPage['per_page'],
page: $commentPage['page'],
context: 'user',
withTrashed: $withTrashedComments,
boardId: $board->id,
board: $board,
);
$comments = $paginated->getCollection();
$commentPagination = [
'current_page' => $paginated->currentPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
'last_page' => $paginated->lastPage(),
'has_more_pages' => $paginated->hasMorePages(),
'total_relation' => $paginated->totalRelation()->value,
'total_is_exact' => $paginated->totalRelation()->isExact(),
'result_cap' => $paginated->resultCap(),
];
} else {
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id, board: $board);
}
// 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
$user = $request->user();
@@ -188,6 +220,29 @@ class PostController extends PublicBaseController
// 정렬된 댓글을 post에 설정
$post->setRelation('comments', $comments);
// 댓글 목록은 상한에서 끊길 수 있다. 끊겼다면 그 사실을 화면에 알린다 —
// 조용히 잘라내면 사용자에게는 "댓글이 그만큼뿐" 으로 보인다.
// 상한 이하면 이미 전량을 받았으므로 세는 쿼리를 추가하지 않는다.
$commentCap = PaginationLimits::resultCap('board.comments');
if ($commentPagination !== null) {
// 페이지네이션 경로는 잘림 여부를 페이지 메타가 그대로 알린다.
$post->comments_pagination = $commentPagination;
$post->comments_total = $commentPagination['total'];
$post->comments_total_is_exact = $commentPagination['total_is_exact'];
} elseif ($commentCap !== null && $comments->count() >= $commentCap) {
$commentTotal = $this->commentService->countCommentsByPostId(
$slug,
$id,
$withTrashedComments,
$board->id
);
$post->comments_truncated = true;
$post->comments_total = $commentTotal->total;
$post->comments_total_is_exact = $commentTotal->totalRelation()->isExact();
}
// 비밀글 권한 체크 및 content 필터링은 PostResource에서 처리
return $this->successWithResource(
'sirsoft-board::messages.posts.fetch_success',
@@ -477,9 +532,8 @@ class PostController extends PublicBaseController
throw new BoardNotFoundException($slug);
}
// 게시글 조회 (첨부파일 포함)
$post = $this->postService->getPostWithCounts($slug, $id);
$post->setRelation('board', $board);
// 게시글 조회 (첨부파일 포함). 이미 조회한 Board 를 넘겨 재조회를 막는다.
$post = $this->postService->getPostWithCounts($slug, $id, board: $board);
// 비밀번호 검증 (Service 사용)
$password = $request->validated('password');
@@ -953,4 +1007,32 @@ class PostController extends PublicBaseController
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
}
}
/**
* 댓글 페이지네이션 요청 여부와 값을 해석합니다.
*
* `comment_page` 또는 `comment_per_page` 중 하나라도 오면 페이지네이션 경로를 씁니다.
* 상한은 페이지네이션 공통 상한(max_page)과 한 페이지 100건을 따릅니다.
*
* @param Request $request HTTP 요청
* @return array{page: int, per_page: int}|null 페이지 정보 (미요청 시 null)
*/
private function resolveCommentPage(Request $request): ?array
{
if (! $request->has('comment_page') && ! $request->has('comment_per_page')) {
return null;
}
$page = max(1, (int) $request->input('comment_page', 1));
$maxPage = PaginationLimits::maxPage('board.comments');
if ($maxPage !== null) {
$page = min($page, $maxPage);
}
$perPage = (int) $request->input('comment_per_page', 20);
$perPage = max(1, min($perPage, 100));
return ['page' => $page, 'per_page' => $perPage];
}
}
@@ -3,7 +3,9 @@
namespace Modules\Sirsoft\Board\Http\Resources;
use App\Enums\PermissionType;
use App\Enums\TotalRelation;
use App\Http\Resources\BaseApiCollection;
use App\Support\Query\BoundedCount;
use Illuminate\Http\Request;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
@@ -18,9 +20,9 @@ class PostCollection extends BaseApiCollection
use ChecksBoardPermission;
/**
* 전체 일반 게시글(원글) 수
* 전체 일반 게시글(원글) 수 + 정확도
*/
private ?int $totalNormalPosts = null;
private ?BoundedCount $totalNormalPosts = null;
/**
* 정렬 방향
@@ -30,9 +32,9 @@ class PostCollection extends BaseApiCollection
/**
* 전체 일반 게시글 수를 설정합니다.
*
* @param int $total 전체 일반 게시글 수
* @param BoundedCount $total 전체 일반 게시글 수 + 정확도
*/
public function setTotalNormalPosts(int $total): void
public function setTotalNormalPosts(BoundedCount $total): void
{
$this->totalNormalPosts = $total;
}
@@ -56,7 +58,7 @@ class PostCollection extends BaseApiCollection
*/
public function toArray(Request $request): array
{
$normalPostsTotal = $this->totalNormalPosts ?? 0;
$normalPostsTotal = $this->totalNormalPosts?->total() ?? 0;
$currentPage = $this->currentPage();
$perPage = $this->perPage();
$isDescending = $this->orderDirection === 'desc';
@@ -114,15 +116,26 @@ class PostCollection extends BaseApiCollection
/**
* 시작 순번을 계산합니다.
*
* 내림차순 순번은 "전체 몇 건 중 몇 번째" 라 총 건수를 알아야 나온다. 총 건수가 상한에
* 걸려 잘렸으면 그 값으로 역산한 순번은 첫 페이지부터 이미 틀리고(12,027 건인데 10000
* 부터 시작), 상한을 넘어선 페이지에서는 0 과 음수까지 내려간다. 틀린 숫자를 내보내는
* 것보다 내보내지 않는 편이 낫으므로 `last_page` 와 같은 원칙으로 null 을 돌려준다.
*
* 오름차순은 offset 만으로 정해지므로 총 건수가 잘려도 그대로 정확하다.
*
* @param int $total 전체 게시글 수
* @param int $currentPage 현재 페이지
* @param int $perPage 페이지당 항목 수
* @param bool $isDescending 내림차순 여부
* @return int 시작 순번
* @return int|null 시작 순번 (총 건수가 잘린 내림차순이면 null)
*/
private function calculateStartNumber(int $total, int $currentPage, int $perPage, bool $isDescending): int
private function calculateStartNumber(int $total, int $currentPage, int $perPage, bool $isDescending): ?int
{
if ($isDescending) {
if ($this->totalNormalPosts?->isTruncated()) {
return null;
}
// 내림차순: 큰 숫자부터 (28, 27, 26, ...)
return $total - (($currentPage - 1) * $perPage);
}
@@ -154,11 +167,11 @@ class PostCollection extends BaseApiCollection
* 게시글의 순번을 반환합니다.
*
* @param mixed $post 게시글 모델
* @param int &$currentNumber 현재 순번 (참조)
* @param int|null &$currentNumber 현재 순번 (참조, 총 건수가 잘린 내림차순이면 null)
* @param bool $isDescending 내림차순 여부
* @return string|int 순번 또는 라벨
* @return string|int|null 순번 또는 라벨 (순번을 알 수 없으면 null)
*/
private function getRowNumber($post, int &$currentNumber, bool $isDescending): string|int
private function getRowNumber($post, ?int &$currentNumber, bool $isDescending): string|int|null
{
if ($post->is_notice) {
return __('sirsoft-board::messages.post.notice');
@@ -168,6 +181,12 @@ class PostCollection extends BaseApiCollection
return __('sirsoft-board::messages.post.reply');
}
// 총 건수를 모르면 순번도 없다. 여기서 임의의 값을 채우면 화면에는 그럴듯한 숫자가
// 나가지만 실제 위치와 어긋난 값이라 그 사실이 드러나지 않는다.
if ($currentNumber === null) {
return null;
}
$number = $currentNumber;
$currentNumber = $isDescending ? $currentNumber - 1 : $currentNumber + 1;
@@ -190,7 +209,14 @@ class PostCollection extends BaseApiCollection
*/
private function buildPagination(int $total, int $currentPage, int $perPage, bool $isDescending, int $currentPageItemCount): array
{
$lastPage = $total > 0 ? (int) ceil($total / $perPage) : 1;
$isTruncated = (bool) $this->totalNormalPosts?->isTruncated();
// 총 건수가 상한에 걸려 잘렸으면 마지막 페이지 번호를 계산할 수 없다. 1 이나 어림값을
// 채우면 화면이 "여기가 끝" 으로 읽어 그 뒤 행에 도달할 방법이 사라진다. null 로
// 내보내 마지막 페이지 점프만 감추게 하고, "다음" 은 has_more_pages 로 계속 열어 둔다.
$lastPage = $isTruncated
? null
: ($total > 0 ? (int) ceil($total / $perPage) : 1);
[$from, $to] = $this->calculateFromTo($total, $currentPage, $perPage, $isDescending);
@@ -204,6 +230,9 @@ class PostCollection extends BaseApiCollection
'from' => $from,
'to' => $to,
'has_more_pages' => $this->hasMorePages(),
'total_relation' => $this->totalNormalPosts?->totalRelation()->value ?? TotalRelation::Exact->value,
'total_is_exact' => ! $isTruncated,
'result_cap' => $this->totalNormalPosts?->resultCap(),
];
}
@@ -214,7 +243,7 @@ class PostCollection extends BaseApiCollection
* @param int $currentPage 현재 페이지
* @param int $perPage 페이지당 항목 수
* @param bool $isDescending 내림차순 여부
* @return array{int, int} [from, to]
* @return array{int|null, int|null} [from, to]
*/
private function calculateFromTo(int $total, int $currentPage, int $perPage, bool $isDescending): array
{
@@ -223,6 +252,11 @@ class PostCollection extends BaseApiCollection
}
if ($isDescending) {
// 순번과 같은 역산이므로 총 건수가 잘리면 이 값도 성립하지 않는다.
if ($this->totalNormalPosts?->isTruncated()) {
return [null, null];
}
// 내림차순: 큰 숫자 → 작은 숫자
$from = $total - (($currentPage - 1) * $perPage);
$to = max($from - $perPage + 1, 1);
@@ -11,6 +11,7 @@ use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
@@ -62,6 +63,14 @@ class PostResource extends BaseApiResource
'navigation' => $this->navigation ?? null,
'parent' => $this->relationLoaded('parent') ? $this->getParentInfo() : null,
'comments' => $this->relationLoaded('comments') ? CommentResource::collection($this->comments) : null,
// 댓글이 상한에서 끊겼는지. 끊기지 않았으면 false 이고 총 건수는 실린 목록 길이와 같다.
// 조용히 잘라내면 사용자에게는 "댓글이 그만큼뿐" 으로 보이므로 사실을 함께 내보낸다.
'comments_truncated' => (bool) ($this->comments_truncated ?? false),
'comments_total' => $this->comments_total ?? ($this->relationLoaded('comments') ? $this->comments->count() : null),
'comments_total_is_exact' => (bool) ($this->comments_total_is_exact ?? true),
// 댓글 페이지네이션을 요청한 경우에만 실린다 (미요청 시 null — 기존 응답 형태 유지)
'comments_pagination' => $this->comments_pagination ?? null,
'attachments' => $this->getAttachmentsForResponse($request, $slug),
'replies' => $this->relationLoaded('replies') ? static::collection($this->replies) : null,
@@ -580,7 +589,35 @@ class PostResource extends BaseApiResource
return [];
}
$permissionMap = $this->isAdminRequest($request)
// 이 권한 맵은 게시글이 아니라 (게시판, 화면 종류, 사용자) 로만 정해진다.
// 상세 화면의 답글 트리는 같은 게시판의 게시글을 N개 직렬화하므로, 메모가 없으면
// 같은 답을 노드 수만큼 다시 만든다. 캐시는 요청 인스턴스에 두어 요청 단위로 격리된다.
$isAdminRequest = $this->isAdminRequest($request);
$cacheKey = $slug.'|'.($isAdminRequest ? 'admin' : 'user').'|'.(Auth::id() ?? 'guest');
$cache = $request->attributes->get(BoardPermissionCacheKeys::ABILITIES, []);
if (isset($cache[$cacheKey])) {
return $cache[$cacheKey];
}
$abilities = $this->buildAbilities($slug, $isAdminRequest);
$cache[$cacheKey] = $abilities;
$request->attributes->set(BoardPermissionCacheKeys::ABILITIES, $cache);
return $abilities;
}
/**
* 게시판 권한 맵을 실제로 해석합니다.
*
* @param string $slug 게시판 슬러그
* @param bool $isAdminRequest 관리자 화면 요청 여부
* @return array<string, bool> 통합 권한 정보
*/
private function buildAbilities(string $slug, bool $isAdminRequest): array
{
$permissionMap = $isAdminRequest
? [
'can_read' => "sirsoft-board.{$slug}.admin.posts.read",
'can_write' => "sirsoft-board.{$slug}.admin.posts.write",
@@ -610,7 +647,7 @@ class PostResource extends BaseApiResource
// 유저 상세 화면에서만: 관리자 게시판 화면 진입 게이트(Admin 타입 admin.manage).
// Admin 요청에서는 can_manage 가 이미 admin.manage 이므로 중복 노출 불필요.
if (! $this->isAdminRequest($request)) {
if (! $isAdminRequest) {
$abilities['can_access_admin'] = $this->checkPermissionByIdentifier("sirsoft-board.{$slug}.admin.manage");
}
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Helpers\PermissionHelper;
use App\Search\SearchCategoryPayload;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\PostService;
@@ -54,7 +55,7 @@ class SearchPostsListener implements HookListenerInterface
/**
* 훅 이벤트를 처리합니다.
*
* @param mixed ...$args 훅에서 전달된 인수들
* @param mixed ...$args 훅에서 전달된 인수들
* @return void
*/
public function handle(...$args): void
@@ -66,7 +67,7 @@ class SearchPostsListener implements HookListenerInterface
/**
* 검색 파라미터 validation rules 추가
*
* @param array $rules 기존 validation rules
* @param array $rules 기존 validation rules
* @return array 게시판 모듈 파라미터가 추가된 rules
*/
public function addValidationRules(array $rules): array
@@ -80,8 +81,8 @@ class SearchPostsListener implements HookListenerInterface
/**
* 게시글 검색을 수행하고 결과를 반환합니다.
*
* @param array $results 기존 검색 결과
* @param array $context 검색 컨텍스트 (q, type, sort, page, per_page, user, request)
* @param array $results 기존 검색 결과
* @param array $context 검색 컨텍스트 (q, type, sort, page, per_page, user, request)
* @return array 게시글이 추가된 검색 결과
*/
public function searchPosts(array $results, array $context): array
@@ -97,7 +98,7 @@ class SearchPostsListener implements HookListenerInterface
try {
$boardSlug = ($context['request'] ?? null)?->input('board_slug', '') ?? '';
$boards = $this->boardService->getActiveBoardsForSearch(
!empty($boardSlug) ? $boardSlug : null
! empty($boardSlug) ? $boardSlug : null
);
// 게시판별 읽기 권한 필터링 — 권한 없는 게시판 제외
@@ -110,8 +111,11 @@ class SearchPostsListener implements HookListenerInterface
return $isRelevantTab ? $results : $this->withEmptyPostsResult($results);
}
if (!$isRelevantTab) {
return $this->withCountOnlyResult($results, $boards, $q);
if (! $isRelevantTab) {
// 배지를 그리지 않는 화면은 건수 자체를 요청하지 않는다 — 그 경우 집계를 생략한다.
return ($context['include_inactive_counts'] ?? true)
? $this->withCountOnlyResult($results, $boards, $q)
: $this->withEmptyPostsResult($results);
}
$results['posts'] = $this->buildSearchResult($boards, $q, $context);
@@ -125,14 +129,14 @@ class SearchPostsListener implements HookListenerInterface
/**
* 게시글 검색 결과를 프론트엔드 응답 구조로 변환합니다.
*
* @param array $response 기존 응답 구조
* @param array $results 검색 결과 (core.search.results에서 반환된 데이터)
* @param array $context 검색 컨텍스트
* @param array $response 기존 응답 구조
* @param array $results 검색 결과 (core.search.results에서 반환된 데이터)
* @param array $context 검색 컨텍스트
* @return array 게시글 응답이 추가된 구조
*/
public function buildPostsResponse(array $response, array $results, array $context): array
{
if (!isset($results['posts'])) {
if (! isset($results['posts'])) {
return $response;
}
@@ -159,7 +163,7 @@ class SearchPostsListener implements HookListenerInterface
/**
* 빈 게시글 결과를 설정합니다.
*
* @param array $results 기존 검색 결과
* @param array $results 기존 검색 결과
* @return array 빈 posts가 추가된 결과
*/
private function withEmptyPostsResult(array $results): array
@@ -172,17 +176,20 @@ class SearchPostsListener implements HookListenerInterface
/**
* 다른 탭에서 게시글 건수만 조회합니다 (단일 쿼리).
*
* @param array $results 기존 검색 결과
* @param iterable $boards 게시판 목록
* @param string $keyword 검색어
* @param array $results 기존 검색 결과
* @param iterable $boards 게시판 목록
* @param string $keyword 검색어
* @return array count만 포함된 결과
*/
private function withCountOnlyResult(array $results, iterable $boards, string $keyword): array
{
// 다른 탭을 보는 중이라도 탭 배지에는 건수가 필요하다. 상한을 건 집계라
// 대량 매칭에서도 비용이 일정하다.
// 잘린 값을 정확한 것처럼 내보내지 않도록 정확도를 함께 싣는다.
$boardIds = collect($boards)->pluck('id')->all();
$totalCount = $this->postService->countAcrossBoards($boardIds, $keyword);
$count = $this->postService->countAcrossBoards($boardIds, $keyword);
$results['posts'] = ['total' => $totalCount, 'items' => [], 'available_boards' => []];
$results['posts'] = SearchCategoryPayload::fromCountOnly($count, ['available_boards' => []]);
return $results;
}
@@ -190,9 +197,9 @@ class SearchPostsListener implements HookListenerInterface
/**
* 전체 게시판을 단일 쿼리로 검색하고 결과 구조를 생성합니다.
*
* @param iterable $boards 게시판 목록
* @param string $keyword 검색어
* @param array $context 검색 컨텍스트
* @param iterable $boards 게시판 목록
* @param string $keyword 검색어
* @param array $context 검색 컨텍스트
* @return array posts 결과 구조
*/
private function buildSearchResult(iterable $boards, string $keyword, array $context): array
@@ -203,22 +210,45 @@ class SearchPostsListener implements HookListenerInterface
$type = $context['type'] ?? 'all';
$boardIds = collect($boards)->pluck('id')->all();
$extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter()];
// 전체 탭은 최신 N개만, posts 탭은 DB 페이지네이션
/** 조회 결과 항목을 화면 형태로 가공한다. */
$format = fn (iterable $items): array => collect($items)
->map(fn ($post) => $this->formatPostResult($post, $keyword))
->all();
// 전체 탭은 최신 N개만 보여 주므로 깊은 페이지 자체가 없다.
if ($type === 'all') {
$allTabLimit = $context['all_tab_limit'] ?? 5;
$searchResult = $this->postService->searchAcrossBoards($boardIds, $keyword, $sort, $allTabLimit, 1);
} else {
$searchResult = $this->postService->searchAcrossBoards($boardIds, $keyword, $sort, $perPage, $page);
$allTabLimit = (int) ($context['all_tab_limit'] ?? 5);
$searchPage = $this->postService->searchAcrossBoards($boardIds, $keyword, $sort, $allTabLimit, 1);
return SearchCategoryPayload::fromBounded($searchPage, $format($searchPage->items()), $extra);
}
return [
'total' => $searchResult['total'],
'items' => $searchResult['items']->map(
fn ($post) => $this->formatPostResult($post, $keyword)
)->all(),
'available_boards' => $this->boardService->getActiveBoardsListForFilter(),
];
// 커서로 처리할 수 있는 정렬이면 키셋으로 응답한다. 첫 페이지는 커서가 없는 것이
// 정상이며 그때 다음 커서가 발급된다. 커서 없이 깊은 페이지를 직접 지목한 요청
// (딥링크)만 offset 을 유지한다 — 판정은 코어가 하고 이 리스너는 결과 유무만 본다.
$cursorPage = $this->postService->searchAcrossBoardsByCursor(
$boardIds,
$keyword,
$sort,
(int) $perPage,
$context['cursor'] ?? null,
(int) $page
);
if ($cursorPage !== null) {
return SearchCategoryPayload::fromCursor(
$cursorPage,
$this->postService->countAcrossBoards($boardIds, $keyword),
$format($cursorPage->items()),
$extra
);
}
$searchPage = $this->postService->searchAcrossBoards($boardIds, $keyword, $sort, (int) $perPage, (int) $page);
return SearchCategoryPayload::fromBounded($searchPage, $format($searchPage->items()), $extra);
}
// ─── buildPostsResponse 헬퍼 ─────────────────────────
@@ -226,14 +256,17 @@ class SearchPostsListener implements HookListenerInterface
/**
* 전체 탭 응답 구조를 생성합니다.
*
* @param array $postsData 게시글 데이터
* @param array $context 검색 컨텍스트
* @param array $postsData 게시글 데이터
* @param array $context 검색 컨텍스트
* @return array 전체 탭용 응답
*/
private function buildAllTabResponse(array $postsData, array $context): array
{
return [
'total' => $postsData['total'] ?? 0,
'total_relation' => $postsData['total_relation'] ?? null,
'total_is_exact' => $postsData['total_is_exact'] ?? true,
'result_cap' => $postsData['result_cap'] ?? null,
'items' => $postsData['items'] ?? [],
];
}
@@ -241,24 +274,34 @@ class SearchPostsListener implements HookListenerInterface
/**
* posts 탭 응답 구조를 생성합니다 (DB 페이지네이션).
*
* @param array $response 기존 응답
* @param array $postsData 게시글 데이터
* @param array $context 검색 컨텍스트
* @param array $response 기존 응답
* @param array $postsData 게시글 데이터
* @param array $context 검색 컨텍스트
* @return array 페이지네이션이 추가된 응답
*/
private function buildPostsTabResponse(array $response, array $postsData, array $context): array
{
$page = $context['page'] ?? 1;
$perPage = $context['per_page'] ?? 10;
$page = (int) ($context['page'] ?? 1);
$perPage = (int) ($context['per_page'] ?? 10);
$totalItems = $postsData['total'] ?? 0;
$response['posts'] = [
'total' => $totalItems,
'total_relation' => $postsData['total_relation'] ?? null,
'total_is_exact' => $postsData['total_is_exact'] ?? true,
'result_cap' => $postsData['result_cap'] ?? null,
'items' => $postsData['items'] ?? [],
];
$response['current_page'] = $page;
$response['per_page'] = $perPage;
$response['last_page'] = max(1, (int) ceil($totalItems / $perPage));
// 총 건수가 상한에 걸리면 마지막 페이지를 계산할 수 없다 — null 로 알린다.
// 화면은 이 값이 null 이면 마지막 페이지 점프만 감추고, "다음" 은 계속 노출한다.
$response['last_page'] = $postsData['last_page'] ?? null;
$response['has_more_pages'] = $postsData['has_more_pages'] ?? false;
// 커서 응답이면 다음/이전 커서를 함께 실어 화면이 깊은 페이지를 OFFSET 없이 넘긴다.
// offset 응답에서는 두 값이 null 이라 화면이 분기 없이 같은 키를 읽는다.
$response['next_cursor'] = $postsData['next_cursor'] ?? null;
$response['prev_cursor'] = $postsData['prev_cursor'] ?? null;
return $response;
}
@@ -268,8 +311,8 @@ class SearchPostsListener implements HookListenerInterface
/**
* 게시글을 검색 결과 형식으로 변환합니다.
*
* @param object $post 게시글 (board relation 로드 필수)
* @param string $keyword 검색어
* @param object $post 게시글 (board relation 로드 필수)
* @param string $keyword 검색어
* @return array 변환된 게시글 데이터
*/
private function formatPostResult(object $post, string $keyword): array
@@ -288,32 +331,34 @@ class SearchPostsListener implements HookListenerInterface
}
return [
'id' => $post->id,
'title' => $post->title,
'title_highlighted' => $this->highlightKeyword($post->title, $keyword),
'content_preview' => $contentPreview,
'id' => $post->id,
'title' => $post->title,
'title_highlighted' => $this->highlightKeyword($post->title, $keyword),
'content_preview' => $contentPreview,
'content_preview_highlighted' => $contentPreviewHighlighted,
'content_mode' => $contentMode,
'board' => [
'content_mode' => $contentMode,
'board' => [
'slug' => $boardSlug,
'name' => $post->board?->getLocalizedName() ?? '',
],
'board_name' => $post->board?->getLocalizedName() ?? '',
'board_slug' => $boardSlug,
'author_name' => $post->author_name ?? $post->user?->name ?? __('board.anonymous'),
'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')),
'view_count' => $post->view_count ?? 0,
'comment_count' => $post->comments_count ?? 0,
'url' => "/board/{$boardSlug}/{$post->id}",
'board_name' => $post->board?->getLocalizedName() ?? '',
'board_slug' => $boardSlug,
// 모듈 번역 키는 네임스페이스를 붙여야 해석된다. 붙이지 않으면 해석에 실패해도
// 예외 없이 키 문자열이 그대로 화면에 나간다 (실측: 작성자에 "board.anonymous").
'author_name' => $post->author_name ?? $post->user?->name ?? __('sirsoft-board::messages.common.guest'),
'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')),
'view_count' => $post->view_count ?? 0,
'comment_count' => $post->comments_count ?? 0,
'url' => "/board/{$boardSlug}/{$post->id}",
];
}
/**
* 텍스트에서 검색어를 하이라이트 처리합니다.
*
* @param string|null $text 원본 텍스트
* @param string $keyword 검색어
* @param string|null $text 원본 텍스트
* @param string $keyword 검색어
* @return string 하이라이트 처리된 텍스트
*/
private function highlightKeyword(?string $text, string $keyword): string
@@ -333,10 +378,10 @@ class SearchPostsListener implements HookListenerInterface
* HTML 모드: strip_tags로 태그 제거 후 평문 추출
* 텍스트 모드: 태그 문자열을 그대로 보존 (실제 게시글 표시와 동일하게)
*
* @param string|null $content 본문 내용
* @param string $keyword 검색어
* @param int $length 추출할 최대 길이
* @param string $contentMode 콘텐츠 모드 (text|html)
* @param string|null $content 본문 내용
* @param string $keyword 검색어
* @param int $length 추출할 최대 길이
* @param string $contentMode 콘텐츠 모드 (text|html)
* @return string 추출된 미리보기 텍스트
*/
private function extractContentPreview(?string $content, string $keyword, int $length = 150, string $contentMode = 'text'): string
@@ -4,6 +4,10 @@ namespace Modules\Sirsoft\Board\Repositories;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -47,7 +51,9 @@ class CommentRepository implements CommentRepositoryInterface
$query = Comment::query()
->where('board_id', $resolvedBoardId)
->with(['user', 'user.avatarAttachment', 'parent'])
// parent 는 eager load 하지 않는다 — 부모 댓글도 같은 글의 댓글이라 이 결과셋에
// 이미 들어 있다. 관계로 다시 부르면 같은 집합을 한 번 더 가져온다.
->with(['user', 'user.avatarAttachment'])
->where('post_id', $postId);
// 권한 스코프 필터링 (Service에서 컨텍스트 기반으로 전달)
@@ -68,7 +74,15 @@ class CommentRepository implements CommentRepositoryInterface
});
}
$comments = $query->orderBy('created_at', $orderDirection)->get();
// 한 글의 댓글 전량을 무제한으로 읽지 않는다. 상한을 넘으면 그 지점에서 끊고,
// 화면은 더 있다는 사실을 총 건수로 알 수 있다({@see self::countByPostId()}).
$commentCap = PaginationLimits::resultCap('board.comments');
$comments = $query
->orderBy('created_at', $orderDirection)
->orderBy('id', $orderDirection)
->when($commentCap !== null, fn ($q) => $q->limit($commentCap))
->get();
// 일반 조회(withTrashed=false)에서 부모가 삭제되어 빠진 경우,
// 살아있는 자식이 트리에서 통째로 누락된다.
@@ -240,6 +254,166 @@ class CommentRepository implements CommentRepositoryInterface
return $count;
}
/**
* {@inheritDoc}
*/
public function paginateRootsByPostId(
string $slug,
int $postId,
int $perPage,
int $page = 1,
bool $withTrashed = false,
string $orderDirection = 'DESC',
?string $scopePermission = null,
?int $boardId = null
): BoundedPage {
$resolvedBoardId = $boardId ?? Board::where('slug', $slug)->value('id');
$orderDirection = strtoupper($orderDirection) === 'ASC' ? 'asc' : 'desc';
// 원댓글(최상위)만 페이지네이션한다. 답글은 그 원댓글에 딸린 것이라 페이지 경계로
// 갈라 놓으면 트리가 끊긴다 — 이번 페이지 원댓글의 자손은 아래에서 통째로 읽는다.
$rootQuery = $this->buildVisibleCommentQuery($resolvedBoardId, $postId, $withTrashed, $slug, $scopePermission)
->whereNull('parent_id')
->with(['user', 'user.avatarAttachment'])
->orderBy('created_at', $orderDirection)
// 전순서 보장 — created_at 은 초 단위라 동률에서 페이지 경계가 흔들린다
->orderBy('id', $orderDirection);
$rootPage = BoundedPaginator::paginate(
$rootQuery,
perPage: $perPage,
page: $page,
resultCap: PaginationLimits::resultCap('board.comments'),
);
$rootIds = collect($rootPage->items())->pluck('id')->all();
if (empty($rootIds)) {
return $rootPage;
}
$descendants = $this->loadDescendantsOfRoots($resolvedBoardId, $postId, $rootIds, $withTrashed, $slug, $scopePermission);
$comments = collect($rootPage->items())->concat($descendants);
if (! $withTrashed && $resolvedBoardId) {
$comments = $this->restoreTombstoneParents($comments, $resolvedBoardId, $postId);
}
$sorted = $this->sortByParentChild($comments, $orderDirection);
$this->recalculateDescendantCounts($sorted);
$rootPage->setCollection($sorted);
return $rootPage;
}
/**
* 이번 페이지 원댓글들의 모든 자손 댓글을 깊이별로 읽습니다.
*
* 상한을 두어 답글이 많은 글에서도 한 페이지를 여는 비용이 고정됩니다.
*
* @param int|null $boardId 게시판 ID
* @param int $postId 게시글 ID
* @param array<int, int> $rootIds 이번 페이지 원댓글 ID
* @param bool $withTrashed 삭제 포함 여부
* @param string $slug 게시판 슬러그
* @param string|null $scopePermission 권한 스코프 식별자
* @return Collection 자손 댓글 컬렉션
*/
private function loadDescendantsOfRoots(?int $boardId, int $postId, array $rootIds, bool $withTrashed, string $slug, ?string $scopePermission): Collection
{
$cap = PaginationLimits::resultCap('board.comments');
$collected = new Collection;
$currentLevel = $rootIds;
$seen = array_flip($rootIds);
while (! empty($currentLevel)) {
$remaining = $cap === null ? null : max(0, $cap - $collected->count());
if ($remaining === 0) {
break;
}
$level = $this->buildVisibleCommentQuery($boardId, $postId, $withTrashed, $slug, $scopePermission)
->whereIn('parent_id', $currentLevel)
->with(['user', 'user.avatarAttachment'])
->orderBy('id', 'asc')
->when($remaining !== null, fn ($q) => $q->limit($remaining))
->get();
if ($level->isEmpty()) {
break;
}
$collected = $collected->concat($level);
// 방문 집합으로 유한 종료를 보장한다 (오염된 parent_id 가 순환을 만들어도 멈춘다)
$currentLevel = $level->pluck('id')
->reject(fn ($id) => isset($seen[$id]))
->each(fn ($id) => $seen[$id] = true)
->all();
}
return $collected;
}
/**
* 노출 대상 댓글 쿼리를 구성합니다 (권한 스코프 + 삭제 표시 규칙 공통).
*
* @param int|null $boardId 게시판 ID
* @param int $postId 게시글 ID
* @param bool $withTrashed 삭제 포함 여부
* @param string $slug 게시판 슬러그
* @param string|null $scopePermission 권한 스코프 식별자
* @return Builder 구성된 쿼리
*/
private function buildVisibleCommentQuery(?int $boardId, int $postId, bool $withTrashed, string $slug, ?string $scopePermission)
{
$query = Comment::query()
->where('board_id', $boardId)
->where('post_id', $postId);
PermissionHelper::applyPermissionScope($query, $scopePermission ?? "sirsoft-board.{$slug}.admin.comments.read");
if ($withTrashed) {
$query->withTrashed();
} else {
// 게시글 삭제로 함께 숨겨진(cascade) 댓글은 사용자가 지운 것이 아니므로 노출한다.
$query->withTrashed()
->where(function ($q) {
$q->whereNull('deleted_at')
->orWhere('trigger_type', TriggerType::Cascade->value);
});
}
return $query;
}
/**
* {@inheritDoc}
*/
public function countByPostId(string $slug, int $postId, bool $withTrashed = false, ?int $boardId = null): BoundedCount
{
$resolvedBoardId = $boardId ?? Board::where('slug', $slug)->value('id');
$query = Comment::query()
->where('board_id', $resolvedBoardId)
->where('post_id', $postId);
if ($withTrashed) {
$query->withTrashed();
} else {
$query->withTrashed()
->where(function ($q) {
$q->whereNull('deleted_at')
->orWhere('trigger_type', TriggerType::Cascade->value);
});
}
return BoundedPaginator::count($query, PaginationLimits::resultCap('board.comments'));
}
/**
* 댓글을 생성합니다.
*
@@ -547,6 +721,7 @@ class CommentRepository implements CommentRepositoryInterface
$orderDirection = $sort === 'oldest' ? 'asc' : 'desc';
// 비활성 게시판 제외 — JOIN 대신 whereNotIn으로 인덱스 활용
// audit:allow query-unbounded-get reason: 게시판은 운영자가 만든 수만큼만 존재한다 (글·댓글 수와 무관)
$inactiveBoardIds = Board::where('is_active', false)->pluck('id')->all();
// 관계/정렬은 지연 조인이 담당한다 (inner 는 키 컬럼만 조회)
@@ -2,6 +2,8 @@
namespace Modules\Sirsoft\Board\Repositories\Contracts;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -25,6 +27,48 @@ interface CommentRepositoryInterface
*/
public function getByPostId(string $slug, int $postId, bool $withTrashed = false, string $orderDirection = 'DESC', ?string $scopePermission = null, ?int $boardId = null): Collection;
/**
* 게시글의 원댓글을 페이지네이션하고, 이번 페이지 원댓글의 답글까지 함께 조립합니다.
*
* 페이지 단위는 **원댓글**입니다. 답글까지 잘라 세면 트리가 페이지 경계에서 끊겨
* 부모 없는 답글이 화면에 남습니다. 총 건수는 상한까지만 세며, 다음 페이지 이동은
* 총 건수와 무관하게 실측으로 판정됩니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param int $perPage 페이지당 원댓글 수
* @param int $page 현재 페이지
* @param bool $withTrashed 삭제된 댓글 포함 여부
* @param string $orderDirection 정렬 방향 (ASC 또는 DESC)
* @param string|null $scopePermission 스코프 권한 식별자
* @param int|null $boardId 게시판 ID (전달 시 재조회 생략)
* @return BoundedPage 원댓글 기준 페이지 (컬렉션은 트리 정렬된 댓글 전체)
*/
public function paginateRootsByPostId(
string $slug,
int $postId,
int $perPage,
int $page = 1,
bool $withTrashed = false,
string $orderDirection = 'DESC',
?string $scopePermission = null,
?int $boardId = null
): BoundedPage;
/**
* 게시글의 댓글 총 건수를 조회합니다.
*
* 목록 조회는 상한에 걸려 잘릴 수 있으므로, 화면이 "더 있다" 를 알 수 있도록
* 총 건수를 따로 제공합니다. 집계에도 같은 상한이 걸려 대량 댓글에서 비용이 일정합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param bool $withTrashed 삭제 댓글 포함 여부
* @param int|null $boardId 게시판 ID (전달 시 재조회 생략)
* @return BoundedCount 댓글 총 건수 (상한 초과 시 상한값 + AtLeast 정확도)
*/
public function countByPostId(string $slug, int $postId, bool $withTrashed = false, ?int $boardId = null): BoundedCount;
/**
* 댓글을 생성합니다.
*
@@ -2,9 +2,12 @@
namespace Modules\Sirsoft\Board\Repositories\Contracts;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
@@ -108,9 +111,10 @@ interface PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 게시판 ID (전달 시 슬러그 재조회 생략)
* @return int 증가된 조회수
*/
public function incrementViewCount(string $slug, int $id): int;
public function incrementViewCount(string $slug, int $id, ?int $boardId = null): int;
/**
* 해당 게시판의 게시글이 공지글인지 여부만 경량 조회합니다.
@@ -154,9 +158,10 @@ interface PostRepositoryInterface
* @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): ?Post;
public function findWithCounts(string $slug, int $id, ?int $boardId = null, ?Board $board = null): ?Post;
/**
* 전체 일반 게시글(원글) 수를 조회합니다.
@@ -165,9 +170,9 @@ interface PostRepositoryInterface
* @param string $slug 게시판 슬러그
* @param array $filters 필터 조건
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @return int 일반 게시글 수 (답글, 공지 제외)
* @return BoundedCount 일반 게시글 수 + 정확도 (답글, 공지 제외)
*/
public function countNormalPosts(string $slug, array $filters = [], bool $withTrashed = false): int;
public function countNormalPosts(string $slug, array $filters = [], bool $withTrashed = false): BoundedCount;
/**
* 이전/다음 게시글을 조회합니다.
@@ -198,19 +203,27 @@ interface PostRepositoryInterface
* @param string $keyword 검색 키워드
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $limit 조회할 최대 항목 수
* @return array{total: int, items: Collection}
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchByKeyword(string $slug, string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array;
public function searchByKeyword(
string $slug,
string $keyword,
string $orderBy = 'created_at',
string $direction = 'desc',
int $perPage = 10,
int $page = 1
): BoundedPage;
/**
* 게시판에서 키워드와 일치하는 게시글 수를 조회합니다.
*
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @return int 일치하는 게시글 수
* @return BoundedCount 일치하는 게시글 수 (정확도 포함)
*/
public function countByKeyword(string $slug, string $keyword): int;
public function countByKeyword(string $slug, string $keyword): BoundedCount;
/**
* 여러 게시판에서 키워드로 게시글을 검색합니다 (단일 쿼리, DB 페이지네이션).
@@ -223,16 +236,44 @@ interface PostRepositoryInterface
* @param int $page 페이지 번호
* @return array{total: int, items: Collection}
*/
public function searchAcrossBoards(array $boardIds, string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $perPage = 10, int $page = 1): array;
public function searchAcrossBoards(
array $boardIds,
string $keyword,
string $orderBy = 'created_at',
string $direction = 'desc',
int $perPage = 10,
int $page = 1
): BoundedPage;
/**
* 여러 게시판에서 키워드로 게시글을 커서(키셋)로 검색합니다.
*
* 커서 적용 가능 여부 판정은 코어가 담당하므로, 이 메서드는 이미 검증된 정렬 키를
* 받아 조회만 수행합니다.
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...]
* @param int $perPage 페이지당 항목 수
* @param string|null $cursor 인코딩된 커서 (첫 페이지면 null)
* @return CursorPaginator 커서 페이지 결과
*/
public function searchAcrossBoardsByCursor(
array $boardIds,
string $keyword,
array $sortKeys,
int $perPage = 10,
?string $cursor = null
): CursorPaginator;
/**
* 여러 게시판에서 키워드와 일치하는 게시글 수를 조회합니다 (단일 쿼리).
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @return int 키워드와 일치하는 게시글 수
* @return BoundedCount 키워드와 일치하는 게시글 수 (정확도 포함)
*/
public function countAcrossBoards(array $boardIds, string $keyword): int;
public function countAcrossBoards(array $boardIds, string $keyword): BoundedCount;
/**
* 사용자의 게시글 활동 통계를 조회합니다.
@@ -5,11 +5,17 @@ namespace Modules\Sirsoft\Board\Repositories;
use App\Enums\PermissionType;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Search\Engines\DatabaseFulltextEngine;
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;
@@ -88,26 +94,25 @@ class PostRepository implements PostRepositoryInterface
{
// 검색
if (! empty($filters['search'])) {
$keyword = $this->escapeLikeKeyword($filters['search']);
// FULLTEXT 와 LIKE 는 이스케이프 규칙이 다르다. LIKE 용으로 이스케이프한 문자열을
// MATCH 에 그대로 넘기면 백슬래시가 검색어의 일부로 들어간다.
$rawKeyword = (string) $filters['search'];
$likeKeyword = $this->escapeLikeKeyword($rawKeyword);
$searchField = $filters['search_field'] ?? 'all';
$query->where(function ($q) use ($keyword, $searchField) {
$query->where(function ($q) use ($rawKeyword, $likeKeyword, $searchField) {
// 제목+내용 검색: FULLTEXT 활용 (all, title_content)
// 코어 헬퍼를 거쳐야 BOOLEAN MODE 연산자(+ - * " 등) 입력이 500 이 되지 않는다.
if ($searchField === 'all' || $searchField === 'title_content') {
if (DatabaseFulltextEngine::supportsFulltext()) {
$q->orWhereRaw('MATCH(`title`, `content`) AGAINST(? IN BOOLEAN MODE)', [$keyword]);
} else {
$q->orWhere('title', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%");
}
KeywordSearch::apply($q, ['title', 'content'], $rawKeyword, 'or');
}
// 작성자 검색
if ($searchField === 'all' || $searchField === 'author' || $searchField === 'author_name') {
$q->orWhere('author_name', 'like', "%{$keyword}%")
->orWhereHas('user', function ($uq) use ($keyword) {
$uq->where('name', 'like', "%{$keyword}%")
->orWhere('email', 'like', "%{$keyword}%");
$q->orWhere('author_name', 'like', "%{$likeKeyword}%")
->orWhereHas('user', function ($uq) use ($likeKeyword) {
$uq->where('name', 'like', "%{$likeKeyword}%")
->orWhere('email', 'like', "%{$likeKeyword}%");
});
}
});
@@ -301,19 +306,27 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 게시판 ID (전달 시 슬러그 재조회 생략)
* @return int 증가된 조회수
*/
public function incrementViewCount(string $slug, int $id): int
public function incrementViewCount(string $slug, int $id, ?int $boardId = null): int
{
$board = Board::where('slug', $slug)->first();
// 상세 화면은 이미 게시판을 조회한 뒤 여기로 온다. 슬러그로 다시 찾으면 같은 요청에서
// 게시판을 두 번 읽는다 — 호출자가 알고 있으면 그대로 받는다.
if ($boardId === null) {
$boardId = Board::where('slug', $slug)->value('id');
}
Post::where('board_id', $board?->id)
Post::where('board_id', $boardId)
->where('id', $id)
->increment('view_count');
$post = $this->find($slug, $id);
return $post?->view_count ?? 0;
// 증가된 값만 필요하다. find() 는 게시판을 또 찾고 user 관계까지 적재하므로
// 조회수 하나 읽자고 쓰기에는 과하다.
return (int) (Post::withTrashed()
->where('board_id', $boardId)
->where('id', $id)
->value('view_count') ?? 0);
}
/**
@@ -417,11 +430,16 @@ class PostRepository implements PostRepositoryInterface
* @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): ?Post
public function findWithCounts(string $slug, int $id, ?int $boardId = null, ?Board $board = null): ?Post
{
// boardId가 전달되면 Board 모델 재조회 없이 직접 사용
// 호출자가 이미 손에 쥔 Board 를 넘기면 그 인스턴스를 그대로 쓴다 (#519 F3).
// $board 는 아래 답글 로딩에서 참조하므로 어느 경로로 오든 정의돼 있어야 한다
// (초기화가 없으면 boardId 를 받은 경로에서 미정의 변수 경고가 난다).
$boardId = $board?->id ?? $boardId;
if (! $boardId) {
$board = Board::where('slug', $slug)->first();
$boardId = $board?->id;
@@ -434,12 +452,13 @@ class PostRepository implements PostRepositoryInterface
|| $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([
'user',
'user.avatarAttachment',
'board',
...$relations,
'parent' => function ($query) {
$query->withTrashed()
->with('user');
@@ -462,6 +481,11 @@ class PostRepository implements PostRepositoryInterface
// 모든 하위 답글을 재귀적으로 로드하여 트리 구조로 설정
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);
@@ -529,9 +553,9 @@ class PostRepository implements PostRepositoryInterface
* @param string $slug 게시판 슬러그
* @param array $filters 필터 조건
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @return int 일반 게시글 수 (답글, 공지 제외)
* @return BoundedCount 일반 게시글 수 + 정확도 (답글, 공지 제외)
*/
public function countNormalPosts(string $slug, array $filters = [], bool $withTrashed = false): int
public function countNormalPosts(string $slug, array $filters = [], bool $withTrashed = false): BoundedCount
{
$board = Board::where('slug', $slug)->first();
@@ -554,7 +578,11 @@ class PostRepository implements PostRepositoryInterface
// 필터 적용
$this->applyFilters($query, $filters);
return $query->count();
// 총 건수는 상한까지만 센다. 검색어가 걸린 목록은 매칭 수가 데이터 증가에 비례하고,
// 이 값은 목록 화면이 열릴 때마다 계산된다. 상한 이하면 지금과 값이 같고, 초과할
// 때만 "이상" 으로 보고한다 — 페이지 이동은 simplePaginate 의 per_page + 1 실측이
// 담당하므로 상한과 무관하게 끝까지 열려 있다.
return BoundedPaginator::count($query, PaginationLimits::resultCap('board.posts'));
}
/**
@@ -862,11 +890,16 @@ class PostRepository implements PostRepositoryInterface
}
// 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()
@@ -884,6 +917,10 @@ class PostRepository implements PostRepositoryInterface
$levelQuery->with($relations);
}
if ($replyCap !== null) {
$levelQuery->limit(max(1, $replyCap - $allReplies->count()));
}
$levelReplies = $levelQuery->get($columns);
if ($levelReplies->isEmpty()) {
@@ -892,17 +929,37 @@ class PostRepository implements PostRepositoryInterface
$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, $replies): void {
$directReplies = $replies->where('parent_id', $postId)->sortBy('id');
foreach ($directReplies as $reply) {
$appendReplies = function (int $postId) use (&$appendReplies, &$mergedItems, $repliesByParent): void {
foreach ($repliesByParent->get($postId) ?? [] as $reply) {
$mergedItems->push($reply);
$appendReplies($reply->id);
}
@@ -1273,23 +1330,32 @@ class PostRepository implements PostRepositoryInterface
* @param string $keyword 검색 키워드
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $limit 조회할 최대 항목 수
* @return array{total: int, items: \Illuminate\Database\Eloquent\Collection}
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchByKeyword(string $slug, string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array
{
$query = $this->buildPublicSearchQuery($slug, $keyword);
$total = $query->count();
$items = $query->with('user')
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)
->limit($limit)
->get();
// 전순서 보장 — 정렬 컬럼이 비고유라 페이지 경계에서 행이 겹치거나 샐 수 있다
->orderBy('id', $direction === 'asc' ? 'asc' : 'desc');
return [
'total' => $total,
'items' => $items,
];
return BoundedPaginator::paginate(
$query,
perPage: $perPage,
page: $page,
resultCap: PaginationLimits::resultCap('search'),
);
}
/**
@@ -1297,11 +1363,14 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @return int 일치하는 게시글 수
* @return BoundedCount 일치하는 게시글 수 (정확도 포함)
*/
public function countByKeyword(string $slug, string $keyword): int
public function countByKeyword(string $slug, string $keyword): BoundedCount
{
return $this->buildPublicSearchQuery($slug, $keyword)->count();
return BoundedPaginator::count(
$this->buildPublicSearchQuery($slug, $keyword),
PaginationLimits::resultCap('search')
);
}
/**
@@ -1313,23 +1382,53 @@ class PostRepository implements PostRepositoryInterface
* @param string $direction 정렬 방향 (asc, desc)
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return array{total: int, items: \Illuminate\Database\Eloquent\Collection}
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchAcrossBoards(array $boardIds, string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $perPage = 10, int $page = 1): array
{
$query = $this->buildPublicSearchQueryByIds($boardIds, $keyword);
$total = $query->count();
$items = (clone $query)
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)
->forPage($page, $perPage)
->get();
->orderBy('id', $direction === 'asc' ? 'asc' : 'desc');
return [
'total' => $total,
'items' => $items,
];
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,
);
}
/**
@@ -1337,11 +1436,14 @@ class PostRepository implements PostRepositoryInterface
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @return int 키워드와 일치하는 게시글 수
* @return BoundedCount 키워드와 일치하는 게시글 수 (정확도 포함)
*/
public function countAcrossBoards(array $boardIds, string $keyword): int
public function countAcrossBoards(array $boardIds, string $keyword): BoundedCount
{
return $this->buildPublicSearchQueryByIds($boardIds, $keyword)->count();
return BoundedPaginator::count(
$this->buildPublicSearchQueryByIds($boardIds, $keyword),
PaginationLimits::resultCap('search')
);
}
/**
@@ -1387,22 +1489,17 @@ class PostRepository implements PostRepositoryInterface
/**
* 키워드 검색 조건을 쿼리에 적용합니다.
*
* FULLTEXT 인덱스가 지원되면 MATCH...AGAINST를, 아니면 LIKE fallback을 사용합니다.
* 어떤 조건이 붙는지는 활성 검색 엔진이 정합니다. 저장소는 "이 컬럼들로 이 키워드를
* 걸어라" 만 말하고 엔진 종류를 알지 않습니다 — 구체 엔진을 여기서 지목하면 플러그인이
* 등록한 검색 엔진이 호출될 기회 자체를 잃습니다. 정제·폴백(LIKE, 와일드카드 escape
* 포함)은 모두 코어 해석기가 단독으로 수행합니다.
*
* @param Builder $query 쿼리 빌더
* @param string $keyword 검색 키워드
*/
private function applyKeywordSearch(Builder $query, string $keyword): void
{
if (DatabaseFulltextEngine::supportsFulltext()) {
$query->whereRaw('MATCH(`title`, `content`) AGAINST(? IN BOOLEAN MODE)', [$keyword]);
} else {
$escapedKeyword = $this->escapeLikeKeyword($keyword);
$query->where(function ($q) use ($escapedKeyword) {
$q->where('title', 'like', "%{$escapedKeyword}%")
->orWhere('content', 'like', "%{$escapedKeyword}%");
});
}
KeywordSearch::apply($query, ['title', 'content'], $keyword);
}
/**
@@ -1595,6 +1692,7 @@ class PostRepository implements PostRepositoryInterface
*/
private function getInactiveBoardIds(): array
{
// audit:allow query-unbounded-get reason: 게시판은 운영자가 만든 수만큼만 존재한다 (글 수와 무관)
return Board::where('is_active', false)->pluck('id')->all();
}
@@ -3,9 +3,11 @@
namespace Modules\Sirsoft\Board\Repositories;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\FiltersByDateRange;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Search\KeywordSearch;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -29,6 +31,7 @@ use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
*/
class ReportRepository implements ReportRepositoryInterface
{
use FiltersByDateRange;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
@@ -92,6 +95,7 @@ class ReportRepository implements ReportRepositoryInterface
sort: [['column' => 'created_at', 'direction' => 'desc']],
perPage: $perPage,
relations: ['board', 'author', 'processor'],
resultCap: PaginationLimits::resultCap('admin.reports'),
);
}
@@ -358,6 +362,7 @@ class ReportRepository implements ReportRepositoryInterface
// 대상 글 ID — 게시글 신고는 target_id 자체, 댓글 신고는 그 댓글이 달린 글
$outer->addSelect(['target_post_id_comment' => $targetColumn(Comment::class, 'comment', 'post_id')]);
},
resultCap: PaginationLimits::resultCap('admin.reports'),
);
// 갈래별로 나눠 읽은 대상 정보를 화면이 쓰는 단일 속성으로 합친다.
@@ -398,7 +403,7 @@ class ReportRepository implements ReportRepositoryInterface
// 추가 필드: post_title (paginateGrouped 전용 — logs 첫 번째 snapshot 기준)
if (in_array('post_title', $extraFields) && ($searchField === 'all' || $searchField === 'post_title')) {
$q->orWhereHas('logs', function ($lq) use ($keyword) {
DatabaseFulltextEngine::whereFulltext($lq, 'snapshot', $keyword);
KeywordSearch::apply($lq, 'snapshot', $keyword);
$lq->oldest();
});
}
@@ -406,7 +411,7 @@ class ReportRepository implements ReportRepositoryInterface
// 게시판명 검색
if ($searchField === 'all' || $searchField === 'board_name') {
$q->orWhereHas('board', function ($bq) use ($keyword) {
DatabaseFulltextEngine::whereFulltext($bq, 'name', $keyword);
KeywordSearch::apply($bq, 'name', $keyword);
});
}
@@ -463,9 +468,11 @@ class ReportRepository implements ReportRepositoryInterface
*/
public function countTodayReportsByUser(int $userId): int
{
return ReportLog::where('reporter_id', $userId)
->whereDate('created_at', today())
->count();
// whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다.
$query = ReportLog::where('reporter_id', $userId);
$this->applyDayFilter($query, 'created_at', today());
return $query->count();
}
/**
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Board\Services;
use App\Contracts\Extension\CacheInterface;
use App\Enums\PermissionType;
use App\Extension\HookManager;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -86,6 +88,66 @@ class CommentService
return $this->commentRepository->getByPostId($slug, $postId, $withTrashed, $orderDirection, $scopePermission, $boardId);
}
/**
* 특정 게시글의 댓글을 원댓글 기준으로 페이지네이션해 조회합니다.
*
* 댓글이 상한을 넘는 글에서도 뒤쪽 댓글에 도달할 수 있게 하는 경로입니다.
* 정렬 방향·권한 스코프 해석은 전량 조회와 동일합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param int $perPage 페이지당 원댓글 수
* @param int $page 현재 페이지
* @param string $context 컨텍스트 (admin 또는 user)
* @param bool|null $withTrashed 삭제된 댓글 포함 여부 (null이면 권한으로 결정)
* @param int|null $boardId 게시판 ID
* @param Board|null $board 게시판 모델
* @return BoundedPage 원댓글 기준 페이지 (트리 정렬된 댓글 컬렉션)
*/
public function paginateCommentsByPostId(
string $slug,
int $postId,
int $perPage,
int $page = 1,
string $context = 'admin',
?bool $withTrashed = null,
?int $boardId = null,
?Board $board = null
): BoundedPage {
if ($withTrashed === null) {
$withTrashed = $this->checkBoardPermission($slug, 'admin.control')
|| $this->checkBoardPermission($slug, 'admin.manage')
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
}
if (! $board) {
$board = $boardId
? $this->boardRepository->find($boardId)
: $this->boardRepository->findBySlug($slug);
}
$boardId = $boardId ?? $board?->id;
$commentOrder = $board?->comment_order;
$orderDirection = $commentOrder instanceof \BackedEnum
? $commentOrder->value
: ($commentOrder ?? 'DESC');
$scopePermission = $context === 'admin'
? "sirsoft-board.{$slug}.admin.comments.read"
: "sirsoft-board.{$slug}.comments.read";
return $this->commentRepository->paginateRootsByPostId(
$slug,
$postId,
$perPage,
$page,
$withTrashed,
$orderDirection,
$scopePermission,
$boardId
);
}
/**
* ID로 댓글을 조회합니다.
*
@@ -96,6 +158,33 @@ class CommentService
*
* @throws ModelNotFoundException
*/
/**
* 특정 게시글의 댓글 총 건수를 조회합니다.
*
* 목록이 상한에서 끊긴 경우에만 부르도록 설계돼 있습니다 — 상한 이하면 이미 전량을
* 받았으므로 세는 쿼리를 다시 실행할 이유가 없습니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @param bool $withTrashed 삭제 댓글 포함 여부
* @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @return BoundedCount 댓글 총 건수 (정확도 포함)
*/
public function countCommentsByPostId(string $slug, int $postId, bool $withTrashed = false, ?int $boardId = null): BoundedCount
{
return $this->commentRepository->countByPostId($slug, $postId, $withTrashed, $boardId);
}
/**
* 댓글 하나를 조회합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 게시글 ID (전달 시 상위 스코프까지 검사)
* @return Comment 조회된 댓글
*
* @throws ModelNotFoundException 댓글이 없거나 상위 스코프가 다를 때
*/
public function getComment(string $slug, int $id, ?int $postId = null): Comment
{
return $this->commentRepository->findOrFail($slug, $id, $postId);
@@ -3,12 +3,16 @@
namespace Modules\Sirsoft\Board\Services;
use App\Contracts\Extension\CacheInterface;
use App\Enums\TotalRelation;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Search\SearchPagePolicy;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -30,6 +34,26 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
*/
class PostService
{
/**
* 검색 정렬 이름 → [실제 컬럼, 방향] 선언
*
* 코어({@see SearchPagePolicy})가 이 선언을 읽어 커서 적용 여부를 판정한다.
* 여기에 없는 정렬 이름(관련도순 등)은 커서로 처리하지 않고 offset 을 유지한다.
*/
public const SEARCH_SORT_MAP = [
'latest' => ['created_at', 'desc'],
'oldest' => ['created_at', 'asc'],
'views' => ['view_count', 'desc'],
'popular' => ['view_count', 'desc'],
];
/**
* 커서(키셋) 경계로 쓸 수 있는 실제 컬럼 선언
*
* 커서는 정렬 키를 WHERE 절 경계로 삼으므로 계산값·별칭은 넣을 수 없다.
*/
public const SEARCH_CURSOR_COLUMNS = ['created_at', 'view_count'];
/**
* PostService 생성자
*
@@ -86,11 +110,11 @@ class PostService
* @param array $filters 필터 조건
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용)
* @return int 일반 게시글 수 (답글, 공지 제외)
* @return BoundedCount 일반 게시글 수 + 정확도 (답글, 공지 제외)
*
* @throws ModelNotFoundException 게시판을 찾을 수 없는 경우
*/
public function getTotalNormalPosts(string $slug, array $filters = [], bool $withTrashed = false, string $context = 'admin'): int
public function getTotalNormalPosts(string $slug, array $filters = [], bool $withTrashed = false, string $context = 'admin'): BoundedCount
{
// 게시판 존재성 검증
$this->validateBoardExists($slug);
@@ -115,9 +139,9 @@ class PostService
* @param array $filters 필터 조건
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @param string $context 컨텍스트 (admin 또는 user)
* @return int 일반 게시글 수
* @return BoundedCount 일반 게시글 수 + 정확도
*/
public function getCachedNormalPostCount(string $slug, int $boardId, array $filters = [], bool $withTrashed = false, string $context = 'admin'): int
public function getCachedNormalPostCount(string $slug, int $boardId, array $filters = [], bool $withTrashed = false, string $context = 'admin'): BoundedCount
{
// 필터가 적용된 경우 캐시 미사용 — 실제 COUNT 실행
$hasActiveFilters = ! empty($filters['search'])
@@ -127,18 +151,32 @@ class PostService
|| ! empty($filters['created_at_from'])
|| ! empty($filters['created_at_to']);
if ($hasActiveFilters || $withTrashed) {
if ($hasActiveFilters) {
return $this->getTotalNormalPosts($slug, $filters, $withTrashed, $context);
}
$cacheKey = "board_normal_count_{$boardId}";
// 관리자 목록은 삭제글 포함(withTrashed=true)이 기본이라, 이 조건에서 캐시를 건너뛰면
// 관리자가 목록을 열 때마다 전체 COUNT 가 실행된다. 포함 여부는 결과가 달라지는
// 축이므로 캐시를 우회하는 대신 **키를 나눈다** — 두 값이 서로를 덮어쓰지 않는다.
$cacheKey = $withTrashed
? "board_normal_count_{$boardId}_with_trashed"
: "board_normal_count_{$boardId}";
return $this->cache->remember(
// 캐시에는 값 객체가 아니라 원시 배열을 담는다. 드라이버마다 직렬화 방식이 달라
// 객체를 그대로 넣으면 복원 시 클래스 정의에 묶이고, 정확도 필드가 하나라도
// 빠지면 잘린 건수가 정확한 것처럼 되살아난다.
$cached = $this->cache->remember(
$cacheKey,
fn () => $this->getTotalNormalPosts($slug, $filters, $withTrashed, $context),
fn () => $this->getTotalNormalPosts($slug, $filters, $withTrashed, $context)->toArray(),
(int) g7_core_settings('cache.default_ttl', 86400),
tags: ['board-stats']
);
return new BoundedCount(
(int) ($cached['total'] ?? 0),
TotalRelation::tryFrom($cached['total_relation'] ?? '') ?? TotalRelation::Exact,
$cached['result_cap'] ?? null,
);
}
/**
@@ -484,11 +522,12 @@ class PostService
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 게시판 ID (전달 시 슬러그 재조회 생략)
* @return int 증가된 조회수
*/
public function incrementViewCount(string $slug, int $id): int
public function incrementViewCount(string $slug, int $id, ?int $boardId = null): int
{
return $this->postRepository->incrementViewCount($slug, $id);
return $this->postRepository->incrementViewCount($slug, $id, $boardId);
}
/**
@@ -499,9 +538,10 @@ class PostService
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 게시판 ID (전달 시 슬러그 재조회 생략)
* @return bool 조회수가 증가했으면 true, 이미 조회한 경우 false
*/
public function incrementViewCountOnce(string $slug, int $id): bool
public function incrementViewCountOnce(string $slug, int $id, ?int $boardId = null): bool
{
$identifier = Auth::id() ?? request()->ip();
$key = "post_view_{$slug}_{$id}_{$identifier}";
@@ -510,7 +550,7 @@ class PostService
return false;
}
$this->incrementViewCount($slug, $id);
$this->incrementViewCount($slug, $id, $boardId);
$ttl = (int) g7_module_settings('sirsoft-board', 'spam_security.view_count_cache_ttl', 86400);
$this->cache->put($key, true, $ttl);
@@ -523,23 +563,45 @@ class PostService
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int|null $boardId 이미 검증된 게시판 ID (중복 조회 방지용)
* @param Board|null $board 이미 조회한 게시판 모델 (넘기면 관계 적재까지 생략)
* @param string|null $context 스코프 검사 컨텍스트 ('user' | 'admin'). null 이면 검사하지 않는다
* @return Post 게시글 모델 (카운트 포함)
*
* @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우
* @throws AccessDeniedHttpException 스코프 접근이 거부된 경우 ($context 지정 시)
*/
public function getPostWithCounts(string $slug, int $id, ?int $boardId = null): Post
{
public function getPostWithCounts(
string $slug,
int $id,
?int $boardId = null,
?Board $board = null,
?string $context = null
): Post {
$boardId = $board?->id ?? $boardId;
// boardId가 전달되면 이미 검증된 것이므로 중복 조회 방지
if (! $boardId) {
$this->validateBoardExists($slug);
}
$post = $this->postRepository->findWithCounts($slug, $id, $boardId);
$post = $this->postRepository->findWithCounts($slug, $id, $boardId, $board);
if (! $post) {
throw new ModelNotFoundException(__('sirsoft-board::messages.errors.post_not_found'));
}
// 컨텍스트가 주어지면 스코프 접근 검사를 이 인스턴스로 수행한다.
// 종전에는 권한 판정 전용으로 같은 행을 한 번 더 읽었다 (#519 F3).
if ($context !== null) {
$scopePermission = $context === 'admin'
? "sirsoft-board.{$slug}.admin.posts.read"
: "sirsoft-board.{$slug}.posts.read";
if (! PermissionHelper::checkScopeAccess($post, $scopePermission)) {
throw new AccessDeniedHttpException(__('auth.scope_denied'));
}
}
return $post;
}
@@ -647,23 +709,12 @@ class PostService
throw new ModelNotFoundException(__('sirsoft-board::messages.errors.board_not_found'));
}
// 조회수 증가 (캐시 기반 중복 방지)
$this->incrementViewCountOnce($slug, $id);
// 조회수 증가 (캐시 기반 중복 방지) — 이미 조회한 게시판 ID 를 넘겨 재조회를 막는다
$this->incrementViewCountOnce($slug, $id, $board->id);
// 댓글/첨부파일 카운트 포함하여 게시글 조회 (boardId 전달로 Board 중복 조회 방지)
$post = $this->getPostWithCounts($slug, $id, $board->id);
// 컨텍스트 기반 스코프 접근 검사
$scopePermission = $context === 'admin'
? "sirsoft-board.{$slug}.admin.posts.read"
: "sirsoft-board.{$slug}.posts.read";
if (! PermissionHelper::checkScopeAccess($post, $scopePermission)) {
throw new AccessDeniedHttpException(__('auth.scope_denied'));
}
// board 관계 수동 설정
$post->setRelation('board', $board);
// 댓글/첨부파일 카운트 포함하여 게시글 조회 + 컨텍스트 기반 스코프 접근 검사.
// 이미 조회한 Board 를 넘겨 게시판 재조회와 board 관계 적재를 함께 생략한다.
$post = $this->getPostWithCounts($slug, $id, board: $board, context: $context);
// 댓글 로드 (게시판 comment_order 설정 적용, Board 객체 전달로 중복 조회 방지)
$comments = $this->commentService->getCommentsByPostId($slug, $id, boardId: $board->id, board: $board);
@@ -1118,14 +1169,15 @@ class PostService
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @param string $sort 정렬 옵션
* @param int $limit 조회할 최대 항목 수
* @return array{total: int, items: Collection}
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchByKeyword(string $slug, string $keyword, string $sort = 'latest', int $limit = 10): array
public function searchByKeyword(string $slug, string $keyword, string $sort = 'latest', int $perPage = 10, int $page = 1): BoundedPage
{
[$orderBy, $direction] = $this->resolveSortColumn($sort);
return $this->postRepository->searchByKeyword($slug, $keyword, $orderBy, $direction, $limit);
return $this->postRepository->searchByKeyword($slug, $keyword, $orderBy, $direction, $perPage, $page);
}
/**
@@ -1133,9 +1185,9 @@ class PostService
*
* @param string $slug 게시판 슬러그
* @param string $keyword 검색 키워드
* @return int 일치하는 게시글 수
* @return BoundedCount 일치하는 게시글 수 (정확도 포함)
*/
public function countByKeyword(string $slug, string $keyword): int
public function countByKeyword(string $slug, string $keyword): BoundedCount
{
return $this->postRepository->countByKeyword($slug, $keyword);
}
@@ -1148,23 +1200,61 @@ class PostService
* @param string $sort 정렬 옵션
* @param int $perPage 페이지당 항목 수
* @param int $page 페이지 번호
* @return array{total: int, items: Collection}
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
*/
public function searchAcrossBoards(array $boardIds, string $keyword, string $sort = 'latest', int $perPage = 10, int $page = 1): array
public function searchAcrossBoards(array $boardIds, string $keyword, string $sort = 'latest', int $perPage = 10, int $page = 1): BoundedPage
{
[$orderBy, $direction] = $this->resolveSortColumn($sort);
return $this->postRepository->searchAcrossBoards($boardIds, $keyword, $orderBy, $direction, $perPage, $page);
}
/**
* 여러 게시판에서 키워드로 게시글을 커서(키셋)로 검색합니다.
*
* 커서 적용 가능 여부는 코어({@see SearchPagePolicy})가 판정한다. 이 서비스는
* 정렬 선언({@see self::SEARCH_SORT_MAP})만 제공하고 규칙을 다시 쓰지 않는다.
* 적용할 수 없는 정렬(관련도순 등)이면 null 을 돌려주고 호출자는 offset 경로를 쓴다.
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @param string $sort 정렬 옵션
* @param int $perPage 페이지당 항목 수
* @param string|null $cursor 인코딩된 커서 (첫 페이지면 null)
* @param int $page 요청이 지목한 페이지 번호 (딥링크 판정용, 없으면 1)
* @return CursorPaginator|null 커서 페이지 결과 (커서 적용 불가 시 null)
*/
public function searchAcrossBoardsByCursor(
array $boardIds,
string $keyword,
string $sort = 'latest',
int $perPage = 10,
?string $cursor = null,
int $page = 1
): ?CursorPaginator {
$sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP);
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS, $page)) {
return null;
}
return $this->postRepository->searchAcrossBoardsByCursor(
$boardIds,
$keyword,
$sortKeys,
$perPage,
$cursor
);
}
/**
* 여러 게시판에서 키워드와 일치하는 게시글 수를 조회합니다 (단일 쿼리).
*
* @param array $boardIds 검색 대상 게시판 ID 목록
* @param string $keyword 검색 키워드
* @return int 일치하는 게시글 수
* @return BoundedCount 일치하는 게시글 수 (정확도 포함)
*/
public function countAcrossBoards(array $boardIds, string $keyword): int
public function countAcrossBoards(array $boardIds, string $keyword): BoundedCount
{
return $this->postRepository->countAcrossBoards($boardIds, $keyword);
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Sirsoft\Board\Support;
/**
* 게시판 권한 캐시의 요청 속성 키
*
* 권한 판정 결과와 권한 맵은 서로 다른 키에 담기지만 수명이 같다. 한쪽만 비우면
* 권한을 바꾼 뒤에도 이전 권한 맵이 응답에 그대로 실리므로, 두 키를 한 곳에 모아
* 무효화가 항상 함께 이루어지도록 한다.
*
* Trait 이 아니라 별도 클래스에 두는 이유: Trait 상수는 트레이트 이름으로 직접
* 접근할 수 없어서, 트레이트의 static 메서드를 트레이트 이름으로 호출하는 경로
* (`ChecksBoardPermission::clearPermissionCache()`)에서 `self::` 해석이 실패한다.
*/
final class BoardPermissionCacheKeys
{
/**
* 권한 판정 결과 캐시 키 (권한 식별자 ⇒ bool)
*/
public const PERMISSION = '_board_permission_cache';
/**
* 게시판 권한 맵 캐시 키 (게시판·화면·사용자 ⇒ can_* 묶음)
*/
public const ABILITIES = '_board_abilities_cache';
}
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
use App\Http\Middleware\PermissionMiddleware;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
/**
* 게시판 권한 체크 Trait
@@ -125,7 +126,8 @@ trait ChecksBoardPermission
*/
public static function clearPermissionCache(): void
{
request()->attributes->remove('_board_permission_cache');
request()->attributes->remove(BoardPermissionCacheKeys::PERMISSION);
request()->attributes->remove(BoardPermissionCacheKeys::ABILITIES);
}
/**
@@ -145,7 +147,7 @@ trait ChecksBoardPermission
$cacheKey = $userId.'|'.$permission.'|'.$type->value.'|'.($requireAll ? '1' : '0');
// 요청 인스턴스에 권한 캐시를 저장하여 요청 단위로 자동 격리
$cache = $request->attributes->get('_board_permission_cache', []);
$cache = $request->attributes->get(BoardPermissionCacheKeys::PERMISSION, []);
if (isset($cache[$cacheKey])) {
return $cache[$cacheKey];
@@ -167,7 +169,7 @@ trait ChecksBoardPermission
);
$cache[$cacheKey] = $passed;
$request->attributes->set('_board_permission_cache', $cache);
$request->attributes->set(BoardPermissionCacheKeys::PERMISSION, $cache);
return $passed;
}
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Board\Tests;
use App\Http\Middleware\PermissionMiddleware;
use App\Models\Permission;
use App\Models\Role;
use Illuminate\Support\Facades\DB;
@@ -180,10 +181,7 @@ abstract class BoardTestCase extends ModuleTestCase
*/
protected function resetPermissionMiddlewareCache(): void
{
$reflection = new \ReflectionClass(\App\Http\Middleware\PermissionMiddleware::class);
$property = $reflection->getProperty('guestRoleCache');
$property->setAccessible(true);
$property->setValue(null, null);
PermissionMiddleware::clearGuestRoleCache();
}
/**
@@ -0,0 +1,121 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use App\Enums\TotalRelation;
use App\Support\Query\BoundedCount;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 댓글 목록 절단 노출 계약 테스트 (#519)
*
* 한 글의 댓글은 상한을 넘으면 그 지점에서 끊긴다. 끊긴 사실을 알리지 않으면
* 사용자에게는 "댓글이 그만큼뿐" 으로 보인다 — 오류도 빈 화면도 아니라서
* 화면만 봐서는 알아챌 수 없는 유형이다.
*/
class CommentTruncationDisclosureTest extends BoardTestCase
{
/**
* 게시글 하나와 댓글 N 건을 만듭니다.
*
* @param int $commentCount 만들 댓글 수
* @return Post 생성된 게시글
*/
private function seedPostWithComments(int $commentCount): Post
{
$post = Post::create([
'board_id' => $this->board->id,
'title' => '댓글 절단 테스트',
'content' => '본문',
'author_name' => '작성자',
'status' => 'published',
'ip_address' => '127.0.0.1',
]);
for ($i = 0; $i < $commentCount; $i++) {
Comment::create([
'board_id' => $this->board->id,
'post_id' => $post->id,
'content' => '댓글 '.$i,
'author_name' => '댓글쓴이',
'ip_address' => '127.0.0.1',
]);
}
return $post;
}
/**
* 상한 이하면 댓글 전량이 나오고 절단 표시가 없는지 확인
*/
public function test_comments_are_not_truncated_under_cap(): void
{
config(['g7_settings.core.pagination.result_cap' => 100]);
$post = $this->seedPostWithComments(5);
$repository = app(CommentRepositoryInterface::class);
$comments = $repository->getByPostId($this->board->slug, $post->id, boardId: $this->board->id);
$this->assertCount(5, $comments);
$count = $repository->countByPostId($this->board->slug, $post->id, boardId: $this->board->id);
$this->assertInstanceOf(BoundedCount::class, $count);
$this->assertSame(5, $count->total);
$this->assertFalse($count->isTruncated());
}
/**
* 상한을 넘으면 목록이 그 지점에서 끊기고 총 건수가 "이상" 으로 보고되는지 확인
*/
public function test_comment_count_reports_truncation_over_cap(): void
{
config(['g7_settings.core.pagination.result_cap' => 3]);
$post = $this->seedPostWithComments(7);
$repository = app(CommentRepositoryInterface::class);
$comments = $repository->getByPostId($this->board->slug, $post->id, boardId: $this->board->id);
$this->assertLessThanOrEqual(3, $comments->count(), '상한을 넘겨 조회됐다 — 목록이 끊기지 않았다');
$count = $repository->countByPostId($this->board->slug, $post->id, boardId: $this->board->id);
$this->assertSame(3, $count->total);
$this->assertSame(TotalRelation::AtLeast, $count->totalRelation());
$this->assertTrue($count->isTruncated());
}
/**
* 상세 응답이 절단 사실을 필드로 알리는지 확인
*
* 저장소가 정확도를 돌려줘도 화면까지 도달하지 않으면 아무 소용이 없다.
*/
public function test_post_detail_response_discloses_truncation(): void
{
config(['g7_settings.core.pagination.result_cap' => 3]);
$post = $this->seedPostWithComments(7);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$post->id}");
$response->assertOk();
$response->assertJsonPath('data.comments_truncated', true);
$response->assertJsonPath('data.comments_total', 3);
$response->assertJsonPath('data.comments_total_is_exact', false);
}
/**
* 끊기지 않은 경우에는 절단 플래그가 서지 않는지 확인
*/
public function test_post_detail_response_reports_no_truncation_when_under_cap(): void
{
config(['g7_settings.core.pagination.result_cap' => 100]);
$post = $this->seedPostWithComments(4);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$post->id}");
$response->assertOk();
$response->assertJsonPath('data.comments_truncated', false);
$response->assertJsonPath('data.comments_total_is_exact', true);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Tests\Concerns\CountsQueries;
/**
* 게시판 목록 조회의 쿼리 수 회귀 테스트 (#519)
*
* 행 수를 늘려도 쿼리 수가 늘지 않는지 단언한다 — 그것이 N+1 의 정의이고,
* 정상적인 구조 변경(관계 추가 등)에는 반응하지 않는다.
*
* @scenario case=list_query_count
*
* @effects post_list_query_count_stable,
* comment_tree_query_count_stable
*/
class ListQueryCountRegressionTest extends BoardTestCase
{
use CountsQueries;
/**
* 게시글을 원하는 수만큼 만듭니다.
*
* @param int $count 생성할 수
* @param string $prefix 제목 접두
*/
private function seedPosts(int $count, string $prefix): void
{
for ($i = 0; $i < $count; $i++) {
Post::create([
'board_id' => $this->board->id,
'title' => $prefix.' '.$i,
'content' => '본문',
'author_name' => '작성자',
'status' => 'published',
'ip_address' => '127.0.0.1',
]);
}
}
/**
* 게시글 목록: 글 수가 늘어도 쿼리 수가 늘지 않는지 확인
*
* @effects post_list_query_count_stable
*/
public function test_post_list_query_count_is_stable(): void
{
$repository = app(PostRepositoryInterface::class);
$this->seedPosts(5, '초기');
$this->assertQueryCountStableAsDataGrows(
measure: fn () => $repository->paginate($this->board->slug, [], 50, false, $this->board),
grow: fn () => $this->seedPosts(10, '추가'),
context: '게시글 목록',
);
}
/**
* 댓글 트리: 댓글 수가 늘어도 쿼리 수가 늘지 않는지 확인
*
* 종전에는 부모 댓글을 관계로 다시 불러 같은 집합을 한 번 더 가져왔다.
*
* @effects comment_tree_query_count_stable
*/
public function test_comment_tree_query_count_is_stable(): void
{
$repository = app(CommentRepositoryInterface::class);
$post = Post::create([
'board_id' => $this->board->id,
'title' => '댓글 트리',
'content' => '본문',
'author_name' => '작성자',
'status' => 'published',
'ip_address' => '127.0.0.1',
]);
$seedComments = function (int $count, string $prefix) use ($post) {
for ($i = 0; $i < $count; $i++) {
Comment::create([
'board_id' => $this->board->id,
'post_id' => $post->id,
'content' => $prefix.' '.$i,
'author_name' => '댓글쓴이',
'ip_address' => '127.0.0.1',
]);
}
};
$seedComments(5, '초기');
$this->assertQueryCountStableAsDataGrows(
measure: fn () => $repository->getByPostId($this->board->slug, $post->id, boardId: $this->board->id),
grow: fn () => $seedComments(10, '추가'),
context: '댓글 트리',
);
}
}
@@ -0,0 +1,125 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Tests\Concerns\CountsQueries;
/**
* 게시글 상세 조회의 중복 읽기 회귀 테스트 (#519 F3)
*
* 상세 화면은 사이트에서 가장 자주 열리는 경로다. 종전에는 권한 판정용으로 글 행을 한 번
* 읽고, 관계를 붙여 응답에 쓸 글 행을 한 번 더 읽어 **같은 행을 두 번** 가져왔다. 게시판
* 행도 컨트롤러·서비스·저장소가 각각 slug 로 다시 조회해 세 번 읽혔다.
*
* 여기서는 "같은 행을 몇 번 읽는가" 만 단언한다. 관계 로딩(첨부·댓글·답글)은 별개 쿼리이고
* 게시판 설정에 따라 수가 달라지므로 총 쿼리 수를 고정하지 않는다 — 고정하면 정상적인
* 기능 추가마다 깨져 결국 숫자만 올리는 테스트가 된다.
*
* @scenario case=post_detail_duplicate_read
*
* @effects post_detail_reads_post_row_once,
* post_detail_reads_board_row_once
*/
class PostDetailQueryCountRegressionTest extends BoardTestCase
{
use CountsQueries;
private Post $post;
protected function setUp(): void
{
parent::setUp();
$this->post = Post::create([
'board_id' => $this->board->id,
'title' => '상세 중복 조회 점검',
'content' => '본문',
'author_name' => '작성자',
'status' => 'published',
'ip_address' => '127.0.0.1',
]);
}
/**
* 상세 조회 중 실행된 SQL 을 수집합니다.
*
* @return array<int, string> 실행된 SQL 목록
*/
private function captureDetailQueries(): array
{
return $this->captureQueries(function () {
$response = $this->getJson(
'/api/modules/sirsoft-board/boards/'.$this->board->slug.'/posts/'.$this->post->id
);
$response->assertOk();
});
}
/**
* 특정 테이블의 전체 컬럼(`select *`) 조회만 셉니다.
*
* 한 컬럼만 읽는 조회(조회수 갱신 후의 `select view_count` 등)는 목적이 다른 경량
* 질의이므로 중복 읽기 판정에서 제외한다. 여기서 잡고 싶은 것은 같은 행 전체를
* 두 번 가져오는 낭비다.
*
* @param array<int, string> $queries 수집된 SQL 목록
* @param string $table 테이블명 (프리픽스 제외)
* @return int 해당 테이블 전체 컬럼 SELECT 수
*/
private function countFullRowSelectsOn(array $queries, string $table): int
{
$prefixed = DB::getTablePrefix().$table;
return count(array_filter(
$queries,
fn (string $sql) => str_starts_with(strtolower(trim($sql)), 'select *')
&& str_contains($sql, '`'.$prefixed.'`')
));
}
/**
* 글 행을 한 번만 읽는지 확인
*
* 권한 판정과 응답 조립이 같은 인스턴스를 공유해야 한다.
*
* @effects post_detail_reads_post_row_once
*/
public function test_post_row_is_read_once(): void
{
$queries = $this->captureDetailQueries();
$postSelects = $this->countFullRowSelectsOn($queries, 'board_posts');
// 본문 1회 + 답글 트리 탐색 1회. 권한 판정용으로 같은 행을 한 번 더 읽던
// 종전 동작(3회)을 차단한다.
$this->assertLessThanOrEqual(
2,
$postSelects,
'게시글 상세에서 글 전체 조회가 '.$postSelects.'회 — 같은 행을 중복으로 읽고 있다.'
);
}
/**
* 게시판 행을 한 번만 읽는지 확인
*
* 컨트롤러가 이미 조회한 게시판을 서비스·저장소가 slug 로 다시 찾지 않아야 한다.
*
* @effects post_detail_reads_board_row_once
*/
public function test_board_row_is_read_once(): void
{
$queries = $this->captureDetailQueries();
$boardSelects = $this->countFullRowSelectsOn($queries, 'boards');
$this->assertLessThanOrEqual(
1,
$boardSelects,
'게시글 상세에서 게시판 테이블 SELECT 가 '.$boardSelects.'회 — 이미 조회한 게시판을 다시 찾고 있다.'
);
}
}
@@ -0,0 +1,152 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use App\Enums\TotalRelation;
use App\Support\Query\BoundedCount;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\Sirsoft\Board\Http\Resources\PostCollection;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 목록 순번이 상한 총 건수로 역산되어 0·음수로 내려가는 회귀 테스트 (#519)
*
* 내림차순 순번은 "전체 몇 건 중 몇 번째" 라 총 건수를 알아야 계산된다. 총 건수가 상한에
* 걸려 잘리면 그 값으로 역산한 순번은 첫 페이지부터 이미 틀리고, 상한을 넘어가는 페이지에서는
* 0 과 음수까지 내려간다. 실측(12,027 행): 500p=20 → 501p=0/−19 → 601p=−2000.
*
* 틀린 숫자를 내보내는 것보다 내보내지 않는 편이 낫다. `last_page` 를 모를 때 null 로
* 내보내는 것과 같은 원칙으로, 잘린 총 건수에서는 순번을 null 로 둔다. 오름차순은 offset
* 기반이라 총 건수와 무관하게 정확하므로 그대로 둔다.
*
* @scenario case=post_number_bounded_total
*
* @effects post_number_null_when_total_truncated,
* post_number_never_non_positive,
* post_number_exact_when_total_exact,
* post_number_ascending_unaffected_by_truncation
*/
class PostNumberBoundedTotalRegressionTest extends BoardTestCase
{
/**
* 순번 계산에 필요한 최소 형태의 게시글 목록을 만듭니다.
*
* @param int $count 만들 개수
* @return Collection<int, Post> 게시글 컬렉션
*/
private function makePosts(int $count): Collection
{
return collect(range(1, $count))->map(function (int $i) {
$post = new Post;
$post->id = 1000 + $i;
$post->is_notice = false;
$post->parent_id = null;
return $post;
});
}
/**
* 컬렉션을 렌더링해 순번만 뽑아냅니다.
*
* @param int $page 현재 페이지
* @param int $perPage 페이지당 건수
* @param BoundedCount $total 총 건수 + 정확도
* @param string $direction 정렬 방향
* @return array<int, mixed> 순번 목록
*/
private function numbersFor(int $page, int $perPage, BoundedCount $total, string $direction = 'desc'): array
{
$paginator = new LengthAwarePaginator(
$this->makePosts($perPage),
max($total->total(), $page * $perPage),
$perPage,
$page,
);
$collection = new PostCollection($paginator);
$collection->setTotalNormalPosts($total);
$collection->setOrderDirection($direction);
$rendered = $collection->toArray(request());
return collect($rendered['data'])->pluck('number')->all();
}
/**
* 총 건수가 잘리지 않았으면 순번은 지금까지처럼 정확해야 합니다.
*/
public function test_총건수가_정확하면_순번은_총건수부터_내림차순이다(): void
{
$numbers = $this->numbersFor(
page: 1,
perPage: 5,
total: new BoundedCount(120, TotalRelation::Exact, 10000),
);
$this->assertSame([120, 119, 118, 117, 116], $numbers);
}
/**
* 총 건수가 잘렸으면 순번을 지어내지 않아야 합니다.
*/
public function test_총건수가_잘리면_순번은_null_이다(): void
{
$numbers = $this->numbersFor(
page: 1,
perPage: 5,
total: new BoundedCount(10000, TotalRelation::AtLeast, 10000),
);
$this->assertSame([null, null, null, null, null], $numbers);
}
/**
* 상한을 넘어선 깊은 페이지에서도 0·음수가 나오지 않아야 합니다.
*/
public function test_상한을_넘은_페이지에서_0이나_음수_순번이_없다(): void
{
foreach ([501, 502, 601] as $page) {
$numbers = $this->numbersFor(
page: $page,
perPage: 20,
total: new BoundedCount(10000, TotalRelation::AtLeast, 10000),
);
foreach ($numbers as $number) {
$this->assertNotIsInt(
$number,
"{$page} 페이지에서 상한 총 건수로 역산한 순번이 그대로 나왔습니다."
);
}
}
}
/**
* 오름차순은 offset 기반이라 총 건수가 잘려도 그대로 정확해야 합니다.
*/
public function test_오름차순_순번은_총건수가_잘려도_유지된다(): void
{
$numbers = $this->numbersFor(
page: 3,
perPage: 5,
total: new BoundedCount(10000, TotalRelation::AtLeast, 10000),
direction: 'asc',
);
$this->assertSame([11, 12, 13, 14, 15], $numbers);
}
/**
* PHPUnit 11 에는 assertNotIsInt 가 없으므로 의미를 그대로 옮겨 둡니다.
*
* @param mixed $value 검사 대상
* @param string $message 실패 메시지
*/
private function assertNotIsInt(mixed $value, string $message = ''): void
{
$this->assertFalse(is_int($value), $message);
}
}
@@ -0,0 +1,181 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
require_once __DIR__.'/../ModuleTestCase.php';
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Testing\TestResponse;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 게시판 검색어 정제 회귀 테스트 (#407 재발 방지 / #519 C7)
*
* MySQL FULLTEXT 의 BOOLEAN MODE 는 `+ - * " ( ) ~ < > @` 를 연산자로 해석한다.
* 원문 키워드를 그대로 바인딩하면 사용자가 그 문자를 입력하는 것만으로 파싱 오류가 나고
* 목록 화면 전체가 500 이 된다. `#407` 이 코어에 정제기를 넣었지만 게시판은 자기 raw 쿼리를
* 들고 있어 그 수정이 닿지 않았다.
*
* 여기서 단언하는 것은 "검색이 무엇을 찾는가" 가 아니라 **"어떤 입력에도 화면이 죽지 않는가"** 다.
*
* @scenario board-search-keyword-sanitization
*
* @effects boolean_mode_operator_does_not_500,
* html_like_keyword_does_not_500,
* operator_only_keyword_returns_empty,
* normal_keyword_still_matches
*/
class PostSearchKeywordSanitizationTest extends BoardTestCase
{
/**
* 테스트 게시판 slug
*
* @return string 게시판 슬러그
*/
protected function getTestBoardSlug(): string
{
return 'search-sanitize-test';
}
/**
* 테스트 사전 준비를 수행합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->grantDefaultGuestPermissions();
$this->resetPermissionMiddlewareCache();
$this->createTestPost([
'title' => '정상 게시글',
'content' => '검색으로 찾을 수 있는 본문입니다.',
'status' => 'published',
]);
}
/**
* BOOLEAN MODE 연산자를 포함한 검색어가 500 을 내지 않는지 확인
*
* @effects boolean_mode_operator_does_not_500
*/
#[Test]
public function boolean_mode_operators_do_not_break_search(): void
{
foreach (['+', '-', '*', '"', '(', ')', '~', '<', '>', '@', '+검색 -제외', '"닫히지 않은'] as $keyword) {
$response = $this->searchPosts($keyword);
$response->assertStatus(200, "검색어 [{$keyword}] 가 500 을 냈다 — BOOLEAN MODE 정제가 적용되지 않았다");
}
}
/**
* HTML 형태 검색어가 500 을 내지 않는지 확인
*
* `<script>` 는 `<` `>` 가 BOOLEAN MODE 연산자라 정제 없이는 파싱 오류가 난다.
*
* @effects html_like_keyword_does_not_500
*/
#[Test]
public function html_like_keyword_does_not_break_search(): void
{
$response = $this->searchPosts('<script>alert(1)</script>');
$response->assertStatus(200);
}
/**
* 연산자만 입력하면 오류 대신 빈 결과가 나오는지 확인
*
* 정제 후 남는 토큰이 없으면 "찾을 것이 없다" 이지 "오류" 가 아니다.
*
* @effects operator_only_keyword_returns_empty
*/
#[Test]
public function operator_only_keyword_returns_empty_result(): void
{
$response = $this->searchPosts('+++');
$response->assertStatus(200);
$this->assertSame([], $response->json('data.data') ?? [], '연산자만 입력하면 매칭이 없어야 한다');
}
/**
* 정제가 정상 검색어의 토큰을 보존하는지 확인
*
* FULLTEXT 인덱스는 커밋된 행만 색인하므로, 트랜잭션 안에서 만든 게시글은 MATCH 로
* 찾을 수 없다. 그래서 "정제가 정상 검색어를 뭉개지 않는다" 는 명제는 정제기 자체의
* 출력으로 확인하고, 실제 매칭은 아래 LIKE 경로 테스트가 담당한다.
*
* @effects normal_keyword_still_matches
*/
#[Test]
public function sanitizer_preserves_normal_keyword_tokens(): void
{
$this->assertSame(
'"정상"',
DatabaseFulltextEngine::sanitizeBooleanModeKeyword('정상'),
'정제가 정상 검색어까지 걸러 내면 안 된다'
);
$this->assertSame(
'"정상" "게시글"',
DatabaseFulltextEngine::sanitizeBooleanModeKeyword('정상 게시글'),
'여러 토큰은 각각 보존되어야 한다'
);
$this->assertSame(
'"검색" "제외"',
DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+검색 -제외'),
'연산자만 제거하고 토큰은 남긴다'
);
}
/**
* FULLTEXT 를 타지 않는 검색 경로(작성자 LIKE)가 정상 동작하는지 확인
*
* 정제 변경이 목록 검색 전체를 망가뜨리지 않았음을 실제 매칭으로 확인한다.
*
* @effects normal_keyword_still_matches
*/
#[Test]
public function like_path_search_still_matches(): void
{
$this->createTestPost([
'title' => '작성자 검색 대상',
'content' => '본문',
'author_name' => '홍길동',
'status' => 'published',
]);
$this->resetPermissionMiddlewareCache();
$url = "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts?"
.http_build_query(['search' => '홍길동', 'search_field' => 'author']);
$response = $this->getJson($url);
$response->assertStatus(200);
$titles = collect($response->json('data.data') ?? [])->pluck('title')->all();
$this->assertContains('작성자 검색 대상', $titles, 'LIKE 경로 검색이 매칭되어야 한다');
}
/**
* 게시판 목록에 검색어를 얹어 조회합니다.
*
* @param string $keyword 검색어
* @return TestResponse 응답
*/
private function searchPosts(string $keyword)
{
$this->resetPermissionMiddlewareCache();
$url = "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts?"
.http_build_query(['search' => $keyword, 'search_field' => 'title_content']);
return $this->getJson($url);
}
}
@@ -0,0 +1,205 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use App\Enums\TotalRelation;
use App\Search\SearchCategoryPayload;
use App\Support\Query\BoundedCount;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 게시판 검색의 커서(키셋) 전환 회귀 테스트 (#519)
*
* 깊은 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다. 최신순처럼 실제 컬럼
* 기준 정렬은 커서로 넘겨 그 비용을 없애고, 관련도순은 계산값 정렬이라 offset 을 유지한다.
*
* 여기서는 **어떤 경로를 타는지와 응답 형태**를 고정한다. 커서가 모든 행을 정확히 한 번씩
* 훑는다는 성질 자체는 코어 계약 테스트(PaginationContractTest)가 검색과 무관한 모델로
* 이미 고정하고 있고, FULLTEXT 매칭 건수는 InnoDB 전문검색 캐시 상태에 좌우되어
* 이 계층에서 단언할 대상이 아니다.
*
* @scenario case=search_cursor_pagination
*
* @effects search_latest_sort_uses_cursor,
* search_relevance_sort_stays_on_offset,
* search_cursor_payload_keeps_offset_key_set
*/
class SearchCursorPaginationTest extends BoardTestCase
{
private PostService $postService;
protected function setUp(): void
{
parent::setUp();
$this->postService = app(PostService::class);
}
/**
* 검색 대상 게시글을 만듭니다.
*
* @param int $count 생성할 수
*/
private function seedPosts(int $count): void
{
for ($i = 0; $i < $count; $i++) {
Post::create([
'board_id' => $this->board->id,
'author_name' => '작성자',
'ip_address' => '127.0.0.1',
'title' => '커서키워드 게시글 '.$i,
'content' => '커서키워드 본문 '.$i,
'status' => 'published',
'is_secret' => false,
]);
}
}
/**
* 실제 컬럼 기준 정렬은 커서 경로를 타는지 확인
*
* @effects search_latest_sort_uses_cursor
*/
public function test_real_column_sorts_use_cursor(): void
{
// Given: 검색 대상 게시글
$this->seedPosts(3);
// When/Then: 선언된 정렬 전부가 커서 페이지네이터를 돌려준다
foreach (array_keys(PostService::SEARCH_SORT_MAP) as $sort) {
$page = $this->postService->searchAcrossBoardsByCursor(
[$this->board->id],
'커서키워드',
$sort,
4,
'encoded-cursor'
);
$this->assertNotNull($page, $sort.' 정렬은 실제 컬럼이므로 커서를 써야 한다');
}
}
/**
* 관련도순은 커서를 쓰지 않고 offset 을 유지하는지 확인
*
* FULLTEXT 점수는 계산값이라 WHERE 절 경계로 쓸 수 없다.
*
* @effects search_relevance_sort_stays_on_offset
*/
public function test_relevance_sort_stays_on_offset(): void
{
// Given: 검색 대상 게시글
$this->seedPosts(3);
// When: 관련도순으로 커서를 요청
$page = $this->postService->searchAcrossBoardsByCursor(
[$this->board->id],
'커서키워드',
'relevance',
4,
'encoded-cursor'
);
// Then: 커서를 쓰지 않는다 (호출자는 offset 경로로 떨어진다)
$this->assertNull($page);
}
/**
* 커서가 없어도 첫 페이지면 커서 경로로 시작하는지 확인
*
* 첫 페이지에 커서가 없는 것은 정상이다. 이를 "커서 없음" 으로 읽어 offset 으로 돌리면
* 다음 커서가 발급될 자리가 사라져 화면이 영원히 offset 에 머문다.
*
* @effects search_latest_sort_uses_cursor
*/
public function test_first_page_without_cursor_starts_cursor_path(): void
{
// Given: 검색 대상 게시글
$this->seedPosts(3);
// When: 커서 없이 최신순 첫 페이지 요청
$page = $this->postService->searchAcrossBoardsByCursor(
[$this->board->id],
'커서키워드',
'latest',
4,
null
);
// Then: 커서 경로로 처리한다
$this->assertNotNull($page);
}
/**
* 커서 없이 깊은 페이지를 직접 지목한 요청은 offset 을 유지하는지 확인
*
* 주소로 특정 페이지를 열어 둔 링크는 그 페이지를 그대로 보여줘야 한다.
*
* @effects search_relevance_sort_stays_on_offset
*/
public function test_deep_page_without_cursor_stays_on_offset(): void
{
// Given: 검색 대상 게시글
$this->seedPosts(3);
// When: 커서 없이 2 페이지를 직접 지목
$page = $this->postService->searchAcrossBoardsByCursor(
[$this->board->id],
'커서키워드',
'latest',
4,
null,
page: 2
);
// Then: offset 으로 처리한다
$this->assertNull($page);
}
/**
* 커서·offset·건수전용 세 응답의 키 집합이 같은지 확인
*
* 화면이 세 형태를 분기 없이 그릴 수 있어야 한다. 응답 조립을 도메인마다 손으로 하면
* 이 형태가 조용히 갈라진다.
*
* @effects search_cursor_payload_keeps_offset_key_set
*/
public function test_cursor_and_offset_payloads_share_key_set(): void
{
// Given: 검색 대상 게시글과 세 형태의 페이지 결과
$this->seedPosts(3);
$boardIds = [$this->board->id];
$offsetPage = $this->postService->searchAcrossBoards($boardIds, '커서키워드', 'latest', 4, 1);
$cursorPage = $this->postService->searchAcrossBoardsByCursor($boardIds, '커서키워드', 'latest', 4, 'first');
$count = new BoundedCount(3, TotalRelation::Exact, 10000);
$this->assertNotNull($cursorPage);
// When: 세 형태의 페이로드를 만든다
$payloads = [
'offset' => SearchCategoryPayload::fromBounded($offsetPage, []),
'cursor' => SearchCategoryPayload::fromCursor($cursorPage, $count, []),
'count_only' => SearchCategoryPayload::fromCountOnly($count),
];
// Then: 키 집합이 완전히 같다
$reference = array_keys($payloads['offset']);
sort($reference);
foreach ($payloads as $label => $payload) {
$keys = array_keys($payload);
sort($keys);
$this->assertSame($reference, $keys, $label.' 응답의 키 집합이 offset 과 달라졌다');
}
// 커서 응답은 마지막 페이지를 계산하지 않는다 (그 값만 계산 불가하다)
$this->assertNull($payloads['cursor']['last_page']);
// offset 응답에도 커서 키가 존재한다 — 값만 null 이라 화면이 분기하지 않는다
$this->assertNull($payloads['offset']['next_cursor']);
$this->assertNull($payloads['offset']['prev_cursor']);
}
}
@@ -2,9 +2,21 @@
namespace Modules\Sirsoft\Board\Tests;
use App\Contracts\Extension\HookListenerInterface;
use App\Enums\ExtensionStatus;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Http\Middleware\PermissionMiddleware;
use App\Models\Module;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Providers\BoardServiceProvider;
use Tests\TestCase;
/**
@@ -62,7 +74,7 @@ abstract class ModuleTestCase extends TestCase
$this->registerModuleAutoload();
// 모듈 ServiceProvider 등록 (Repository 바인딩)
$this->app->register(\Modules\Sirsoft\Board\Providers\BoardServiceProvider::class);
$this->app->register(BoardServiceProvider::class);
// 모듈 마이그레이션 실행 (boards 테이블 등)
$this->runModuleMigrationIfNeeded();
@@ -73,7 +85,7 @@ abstract class ModuleTestCase extends TestCase
// ModuleManager 메모리 맵에 sirsoft-board 로드 (테스트 환경)
// CoreServiceProvider::boot()에서 loadModules()가 호출되지만,
// 테스트 컨테이너에서 ModuleManager 싱글톤이 비어있는 경우를 대비해 명시적으로 재로드
$this->app->make(\App\Extension\ModuleManager::class)->loadModules();
$this->app->make(ModuleManager::class)->loadModules();
// _bundled 디렉토리 모듈은 loadModules() 가 스캔하지 않아
// 모듈 인스턴스 등록 + 훅 리스너 자동 등록이 누락된다.
@@ -91,11 +103,7 @@ abstract class ModuleTestCase extends TestCase
// PermissionMiddleware::$guestRoleCache 초기화 — 이전 테스트에서 로드된 guest role/permissions 캐시가
// DatabaseTransactions 롤백 후에도 남아있어 다음 테스트의 새 permission 설정이 반영되지 않는 문제 회피.
$middlewareRef = new \ReflectionClass(\App\Http\Middleware\PermissionMiddleware::class);
if ($middlewareRef->hasProperty('guestRoleCache')) {
$prop = $middlewareRef->getProperty('guestRoleCache');
$prop->setValue(null, null);
}
PermissionMiddleware::clearGuestRoleCache();
}
/**
@@ -113,7 +121,7 @@ abstract class ModuleTestCase extends TestCase
*/
private function snapshotHookManager(): void
{
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
$ref = new \ReflectionClass(HookManager::class);
$this->hookSnapshot = [
'hooks' => $ref->getProperty('hooks')->getValue(),
'filters' => $ref->getProperty('filters')->getValue(),
@@ -130,7 +138,7 @@ abstract class ModuleTestCase extends TestCase
return;
}
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
$ref = new \ReflectionClass(HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
@@ -159,7 +167,7 @@ abstract class ModuleTestCase extends TestCase
// 모듈 마이그레이션 실행 (코어 테이블 생성 후)
$this->artisan('migrate', [
'--path' => $this->getModuleBasePath() . '/database/migrations',
'--path' => $this->getModuleBasePath().'/database/migrations',
'--realpath' => true,
]);
@@ -179,13 +187,13 @@ abstract class ModuleTestCase extends TestCase
$moduleClass = \Modules\Sirsoft\Board\Module::class;
if (! class_exists($moduleClass)) {
require_once $this->getModuleBasePath() . '/module.php';
require_once $this->getModuleBasePath().'/module.php';
}
$module = new $moduleClass();
$module = new $moduleClass;
/** @var \App\Extension\ModuleManager $manager */
$manager = $this->app->make(\App\Extension\ModuleManager::class);
/** @var ModuleManager $manager */
$manager = $this->app->make(ModuleManager::class);
// ModuleManager.modules 에 인스턴스 주입
$reflection = new \ReflectionClass($manager);
@@ -203,11 +211,11 @@ abstract class ModuleTestCase extends TestCase
if (! class_exists($listenerClass)) {
continue;
}
if (! in_array(\App\Contracts\Extension\HookListenerInterface::class, class_implements($listenerClass), true)) {
if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) {
continue;
}
try {
\App\Extension\HookListenerRegistrar::register($listenerClass, 'sirsoft-board');
HookListenerRegistrar::register($listenerClass, 'sirsoft-board');
} catch (\Throwable $e) {
// 중복 등록 등 무해한 예외는 무시 (snapshot/restore 패턴이 정리)
}
@@ -221,15 +229,15 @@ abstract class ModuleTestCase extends TestCase
protected function registerModuleAsActive(): void
{
// 이미 등록되어 있으면 스킵
if (\App\Models\Module::where('identifier', 'sirsoft-board')->exists()) {
if (Module::where('identifier', 'sirsoft-board')->exists()) {
return;
}
\App\Models\Module::create([
Module::create([
'identifier' => 'sirsoft-board',
'vendor' => 'sirsoft',
'name' => ['ko' => '게시판', 'en' => 'Board'],
'status' => \App\Enums\ExtensionStatus::Active->value,
'status' => ExtensionStatus::Active->value,
'version' => '1.0.0',
'config' => [],
]);
@@ -240,7 +248,7 @@ abstract class ModuleTestCase extends TestCase
*/
protected function registerModuleAutoload(): void
{
$moduleBasePath = $this->getModuleBasePath() . '/src/';
$moduleBasePath = $this->getModuleBasePath().'/src/';
spl_autoload_register(function ($class) use ($moduleBasePath) {
$prefix = 'Modules\\Sirsoft\\Board\\';
@@ -264,10 +272,10 @@ abstract class ModuleTestCase extends TestCase
*/
protected function registerModuleRoutes(): void
{
$apiRoutesFile = $this->getModuleBasePath() . '/src/routes/api.php';
$apiRoutesFile = $this->getModuleBasePath().'/src/routes/api.php';
if (file_exists($apiRoutesFile)) {
\Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-board')
Route::prefix('api/modules/sirsoft-board')
->name('api.modules.sirsoft-board.')
->middleware('api')
->group($apiRoutesFile);
@@ -279,17 +287,17 @@ abstract class ModuleTestCase extends TestCase
*/
protected function createDefaultRoles(): void
{
\App\Models\Role::firstOrCreate(
Role::firstOrCreate(
['identifier' => 'admin'],
['name' => ['ko' => '관리자', 'en' => 'Administrator']]
);
\App\Models\Role::firstOrCreate(
Role::firstOrCreate(
['identifier' => 'user'],
['name' => ['ko' => '일반 사용자', 'en' => 'User']]
);
\App\Models\Role::firstOrCreate(
Role::firstOrCreate(
['identifier' => 'guest'],
['name' => ['ko' => '비회원', 'en' => 'Guest']]
);
@@ -300,18 +308,18 @@ abstract class ModuleTestCase extends TestCase
* 관리자 역할을 가진 사용자를 생성합니다.
*
* @param array $permissions 추가 권한 목록
* @return \App\Models\User
* @return User
*/
protected function createAdminUser(array $permissions = []): \App\Models\User
protected function createAdminUser(array $permissions = []): User
{
$adminRole = \App\Models\Role::where('identifier', 'admin')->first();
$user = \App\Models\User::factory()->create();
$adminRole = Role::where('identifier', 'admin')->first();
$user = User::factory()->create();
$user->roles()->attach($adminRole->id);
// 추가 권한이 있으면 생성 및 할당
if (! empty($permissions)) {
foreach ($permissions as $permissionIdentifier) {
$permission = \App\Models\Permission::firstOrCreate(
$permission = Permission::firstOrCreate(
['identifier' => $permissionIdentifier],
[
'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier],
@@ -328,14 +336,14 @@ abstract class ModuleTestCase extends TestCase
/**
* 일반 사용자를 생성합니다.
*
* @return \App\Models\User
* @return User
*/
protected function createUser(): \App\Models\User
protected function createUser(): User
{
$userRole = \App\Models\Role::where('identifier', 'user')->first();
$user = \App\Models\User::factory()->create();
$userRole = Role::where('identifier', 'user')->first();
$user = User::factory()->create();
$user->roles()->attach($userRole->id);
return $user;
}
}
}
@@ -2,7 +2,9 @@
namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
use App\Enums\TotalRelation;
use App\Models\User;
use App\Support\Query\BoundedPage;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
@@ -15,6 +17,28 @@ use Tests\TestCase;
*/
class SearchPostsListenerTest extends TestCase
{
/**
* 검색 결과 페이지(BoundedPage)를 만듭니다.
*
* 저장소가 반환하는 계약과 같은 형태를 테스트에서도 그대로 씁니다.
*
* @param Collection $items 페이지 항목
* @param int $total 총 건수
* @return BoundedPage 페이지 결과
*/
private function boundedPage(Collection $items, int $total): BoundedPage
{
return new BoundedPage(
items: $items,
total: $total,
perPage: 5,
currentPage: 1,
totalRelation: TotalRelation::Exact,
resultCap: 10000,
hasMorePages: false,
);
}
private SearchPostsListener $listener;
private PostService $postService;
@@ -32,7 +56,7 @@ class SearchPostsListenerTest extends TestCase
/**
* getSubscribedHooks()가 올바른 훅 목록을 반환하는지 확인
*/
public function test_getSubscribedHooks_returns_correct_hooks(): void
public function test_get_subscribed_hooks_returns_correct_hooks(): void
{
$hooks = SearchPostsListener::getSubscribedHooks();
@@ -48,7 +72,7 @@ class SearchPostsListenerTest extends TestCase
/**
* 권한 있는 게시판만 검색 결과에 포함되는지 확인
*/
public function test_searchPosts_filters_boards_by_permission(): void
public function test_search_posts_filters_boards_by_permission(): void
{
$user = User::factory()->make(['id' => 1001]);
@@ -78,25 +102,22 @@ class SearchPostsListenerTest extends TestCase
$this->postService
->method('searchAcrossBoards')
->with([1], '테스트', $this->anything(), $this->anything(), $this->anything())
->willReturn([
'total' => 1,
'items' => new Collection([
$this->createPostStub(1, 'notice', '공지사항'),
]),
]);
->willReturn($this->boundedPage(new Collection([
$this->createPostStub(1, 'notice', '공지사항'),
]), 1));
$this->boardService
->method('getActiveBoardsListForFilter')
->willReturn([]);
$context = [
'type' => 'all',
'q' => '테스트',
'sort' => 'relevance',
'page' => 1,
'type' => 'all',
'q' => '테스트',
'sort' => 'relevance',
'page' => 1,
'per_page' => 10,
'user' => $user,
'request' => null,
'user' => $user,
'request' => null,
];
$result = $this->listener->searchPosts([], $context);
@@ -108,7 +129,7 @@ class SearchPostsListenerTest extends TestCase
/**
* 모든 게시판 권한이 없을 때 빈 결과를 반환하는지 확인
*/
public function test_searchPosts_returns_empty_when_all_boards_denied(): void
public function test_search_posts_returns_empty_when_all_boards_denied(): void
{
$user = User::factory()->make();
@@ -123,9 +144,9 @@ class SearchPostsListenerTest extends TestCase
$results = [];
$context = [
'type' => 'all',
'q' => '테스트',
'user' => $user,
'type' => 'all',
'q' => '테스트',
'user' => $user,
'request' => null,
];
@@ -141,7 +162,7 @@ class SearchPostsListenerTest extends TestCase
/**
* 빈 검색어일 때 스킵하는지 확인
*/
public function test_searchPosts_skips_when_keyword_is_empty(): void
public function test_search_posts_skips_when_keyword_is_empty(): void
{
$results = [];
$context = ['type' => 'all', 'q' => ''];
@@ -154,7 +175,7 @@ class SearchPostsListenerTest extends TestCase
/**
* formatPostResult()가 created_at(Y-m-d H:i:s 포맷)과 created_at_formatted(표시용) 필드를 반환하는지 확인
*/
public function test_formatPostResult_includes_created_at_and_created_at_formatted(): void
public function test_format_post_result_includes_created_at_and_created_at_formatted(): void
{
$user = User::factory()->make(['id' => 9999]);
@@ -170,23 +191,20 @@ class SearchPostsListenerTest extends TestCase
$this->postService
->method('searchAcrossBoards')
->willReturn([
'total' => 1,
'items' => new Collection([
$this->createPostStub(1, 'notice', '공지사항'),
]),
]);
->willReturn($this->boundedPage(new Collection([
$this->createPostStub(1, 'notice', '공지사항'),
]), 1));
Gate::before(fn ($u) => $u->id === 9999 ? true : null);
$context = [
'type' => 'all',
'q' => '테스트',
'sort' => 'relevance',
'page' => 1,
'type' => 'all',
'q' => '테스트',
'sort' => 'relevance',
'page' => 1,
'per_page' => 10,
'user' => $user,
'request' => null,
'user' => $user,
'request' => null,
];
$result = $this->listener->searchPosts([], $context);
@@ -209,9 +227,9 @@ class SearchPostsListenerTest extends TestCase
/**
* id를 포함하는 Board 스텁 생성
*
* @param int $id 게시판 ID
* @param string $slug 게시판 슬러그
* @param string $name 게시판 이름
* @param int $id 게시판 ID
* @param string $slug 게시판 슬러그
* @param string $name 게시판 이름
* @return object
*/
private function createBoardStub(int $id, string $slug, string $name): object
@@ -241,9 +259,9 @@ class SearchPostsListenerTest extends TestCase
/**
* board relation이 포함된 Post 스텁 생성
*
* @param int $id 게시글 ID
* @param string $boardSlug 게시판 슬러그
* @param string $boardName 게시판 이름
* @param int $id 게시글 ID
* @param string $boardSlug 게시판 슬러그
* @param string $boardName 게시판 이름
* @return object
*/
private function createPostStub(int $id, string $boardSlug, string $boardName): object
@@ -267,16 +285,16 @@ class SearchPostsListenerTest extends TestCase
};
return (object) [
'id' => $id,
'title' => '테스트 게시글',
'content' => '테스트 내용',
'content_mode' => 'text',
'author_name' => '작성자',
'created_at' => now(),
'view_count' => 5,
'id' => $id,
'title' => '테스트 게시글',
'content' => '테스트 내용',
'content_mode' => 'text',
'author_name' => '작성자',
'created_at' => now(),
'view_count' => 5,
'comments_count' => 2,
'user' => null,
'board' => $boardStub,
'user' => null,
'board' => $boardStub,
];
}
}
@@ -24,7 +24,6 @@ use PHPUnit\Framework\Attributes\Test;
*/
class ChecksBoardPermissionTest extends ModuleTestCase
{
/**
* Trait을 사용하는 테스트용 클래스
*/
@@ -48,12 +47,12 @@ class ChecksBoardPermissionTest extends ModuleTestCase
{
use ChecksBoardPermission;
public function testCheckPermissionByIdentifier(string $identifier): bool
public function invokeCheckPermissionByIdentifier(string $identifier): bool
{
return $this->checkPermissionByIdentifier($identifier);
}
public function testCheckBoardPermission(string $slug, string $action, PermissionType $type = PermissionType::User): bool
public function invokeCheckBoardPermission(string $slug, string $action, PermissionType $type = PermissionType::User): bool
{
return $this->checkBoardPermission($slug, $action, $type);
}
@@ -65,16 +64,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
*/
private function resetPermissionMiddlewareCache(): void
{
try {
$reflection = new \ReflectionClass(PermissionMiddleware::class);
if ($reflection->hasProperty('guestRoleCache')) {
$prop = $reflection->getProperty('guestRoleCache');
$prop->setAccessible(true);
$prop->setValue(null, null);
}
} catch (\ReflectionException $e) {
// 무시
}
PermissionMiddleware::clearGuestRoleCache();
}
/**
@@ -111,7 +101,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
request()->setUserResolver(fn () => $user);
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
// Then: true 반환
$this->assertTrue($result);
@@ -140,7 +130,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::login($user);
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.write');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.write');
// Then: false 반환
$this->assertFalse($result);
@@ -168,7 +158,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
// Then: true 반환
$this->assertTrue($result);
@@ -195,7 +185,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: 다른 권한 체크 (guest role에 없는 권한)
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.delete');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.delete');
// Then: false 반환
$this->assertFalse($result);
@@ -211,7 +201,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
// Then: false 반환 (전체 허용도 없음)
$this->assertFalse($result);
@@ -233,7 +223,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
// Then: false 반환 (역할 할당 필수)
$this->assertFalse($result);
@@ -243,7 +233,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
* checkBoardPermission 메서드가 올바른 권한 식별자를 생성하는지 테스트
*/
#[Test]
public function checkBoardPermission_메서드_올바른_식별자_생성(): void
public function check_board_permission_메서드_올바른_식별자_생성(): void
{
// Given: 권한 생성 + guest role에 할당
$permission = Permission::factory()->create([
@@ -259,7 +249,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: checkBoardPermission 호출
$result = $this->traitObject->testCheckBoardPermission('notice', 'posts.read');
$result = $this->traitObject->invokeCheckBoardPermission('notice', 'posts.read');
// Then: 올바른 식별자로 권한 체크됨 (guest role 권한 보유)
$this->assertTrue($result);
@@ -275,7 +265,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: 존재하지 않는 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.nonexistent.permission');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.nonexistent.permission');
// Then: false 반환
$this->assertFalse($result);
@@ -285,7 +275,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
* 회원 권한 체크 우선순위 테스트 (회원이 로그인하면 Gate 먼저 체크)
*/
#[Test]
public function 회원_로그인_시_Gate_우선_체크(): void
public function 회원_로그인_시_gate_우선_체크(): void
{
// Given: guest role에도 권한 부여, 회원에게도 권한 부여
$guestRole = Role::firstOrCreate(
@@ -312,7 +302,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
request()->setUserResolver(fn () => $user);
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
// Then: true 반환 (회원 권한으로 체크됨)
$this->assertTrue($result);
@@ -339,7 +329,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase
Auth::logout();
// When: 권한 체크
$result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
$result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read');
// Then: false 반환 (guest role에 권한 미할당)
$this->assertFalse($result);