diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 17ee4e4e..81fda7fb 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -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 diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index 3e201398..e9ee083a 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -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 @@ -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 여부 | diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_index.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_index.json index 76caecc7..6806aa2e 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_index.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_index.json @@ -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": [ diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json index 55f2f9a9..08029efe 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_posts_index.json @@ -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, diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json index 43d337e9..1288dd10 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json @@ -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", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_reports_detail/_card_report_info.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_reports_detail/_card_report_info.json index 268defa6..e6a96272 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_reports_detail/_card_report_info.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_reports_detail/_card_report_info.json @@ -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": [ diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json index d620642d..31eedfd4 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index 403b57e7..952537ec 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -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": [ { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php index 9b27420b..0be7a176 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php @@ -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]; + } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php index b055e984..7c9f9e31 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php @@ -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); diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php index 926a51ce..e50baa6b 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php @@ -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 통합 권한 정보 + */ + 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"); } diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php index 4e87a04a..d0400174 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php @@ -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 diff --git a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php index 0ce04015..671fe972 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php @@ -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 $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 는 키 컬럼만 조회) diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php index c350c479..e2eb7458 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php @@ -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; + /** * 댓글을 생성합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php index 751c172d..128721f8 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php @@ -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 $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; /** * 사용자의 게시글 활동 통계를 조회합니다. diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index d360a09f..58b4b0b3 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -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(); } diff --git a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php index 38730611..bc373e19 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php @@ -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(); } /** diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php index 0c343050..c7f68546 100644 --- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php @@ -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); diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php index aaa4acac..676b41a4 100644 --- a/modules/_bundled/sirsoft-board/src/Services/PostService.php +++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php @@ -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); } diff --git a/modules/_bundled/sirsoft-board/src/Support/BoardPermissionCacheKeys.php b/modules/_bundled/sirsoft-board/src/Support/BoardPermissionCacheKeys.php new file mode 100644 index 00000000..ce99c420 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Support/BoardPermissionCacheKeys.php @@ -0,0 +1,27 @@ +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; } diff --git a/modules/_bundled/sirsoft-board/tests/BoardTestCase.php b/modules/_bundled/sirsoft-board/tests/BoardTestCase.php index f6a7129e..615f6c24 100644 --- a/modules/_bundled/sirsoft-board/tests/BoardTestCase.php +++ b/modules/_bundled/sirsoft-board/tests/BoardTestCase.php @@ -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(); } /** diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CommentTruncationDisclosureTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CommentTruncationDisclosureTest.php new file mode 100644 index 00000000..3b9b9496 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/CommentTruncationDisclosureTest.php @@ -0,0 +1,121 @@ + $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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/ListQueryCountRegressionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/ListQueryCountRegressionTest.php new file mode 100644 index 00000000..1f94c465 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/ListQueryCountRegressionTest.php @@ -0,0 +1,104 @@ + $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: '댓글 트리', + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/PostDetailQueryCountRegressionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/PostDetailQueryCountRegressionTest.php new file mode 100644 index 00000000..c6ce8cac --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/PostDetailQueryCountRegressionTest.php @@ -0,0 +1,125 @@ +post = Post::create([ + 'board_id' => $this->board->id, + 'title' => '상세 중복 조회 점검', + 'content' => '본문', + 'author_name' => '작성자', + 'status' => 'published', + 'ip_address' => '127.0.0.1', + ]); + } + + /** + * 상세 조회 중 실행된 SQL 을 수집합니다. + * + * @return array 실행된 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 $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.'회 — 이미 조회한 게시판을 다시 찾고 있다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/PostNumberBoundedTotalRegressionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/PostNumberBoundedTotalRegressionTest.php new file mode 100644 index 00000000..e41c62c9 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/PostNumberBoundedTotalRegressionTest.php @@ -0,0 +1,152 @@ + 게시글 컬렉션 + */ + 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 순번 목록 + */ + 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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/PostSearchKeywordSanitizationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/PostSearchKeywordSanitizationTest.php new file mode 100644 index 00000000..94de3d5d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/PostSearchKeywordSanitizationTest.php @@ -0,0 +1,181 @@ + @` 를 연산자로 해석한다. + * 원문 키워드를 그대로 바인딩하면 사용자가 그 문자를 입력하는 것만으로 파싱 오류가 나고 + * 목록 화면 전체가 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 을 내지 않는지 확인 + * + * `'); + + $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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/SearchCursorPaginationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/SearchCursorPaginationTest.php new file mode 100644 index 00000000..5336ebdc --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/SearchCursorPaginationTest.php @@ -0,0 +1,205 @@ +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']); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php index ab52c8dd..6c6890a0 100644 --- a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php +++ b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php @@ -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; } -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php index d74a9b76..35e3724c 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php @@ -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, ]; } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php index 2f34b9b3..bd599147 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php @@ -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);