feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수

공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.

- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
 경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
 (전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
 거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
 change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
 (ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
This commit is contained in:
HeuJung
2026-08-17 17:04:44 +09:00
parent 60652fdc07
commit 3ebbda5d86
236 changed files with 6324 additions and 437 deletions
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 모델 직접 조회를 Service 경유로 바꾼 내부 리팩토링 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Admin\Identity;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -8,6 +10,7 @@ use App\Http\Requests\Admin\Identity\UpdateIdentityMessageTemplateRequest;
use App\Http\Resources\Admin\Identity\IdentityMessageTemplateResource;
use App\Models\IdentityMessageTemplate;
use App\Services\IdentityMessageTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
@@ -29,7 +32,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
*
* @param UpdateIdentityMessageTemplateRequest $request
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function update(UpdateIdentityMessageTemplateRequest $request, IdentityMessageTemplate $template)
{
@@ -54,7 +57,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 활성/비활성 토글.
*
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function toggleActive(IdentityMessageTemplate $template)
{
@@ -79,13 +82,13 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 변수 치환 미리보기.
*
* @param PreviewIdentityMessageTemplateRequest $request
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function preview(PreviewIdentityMessageTemplateRequest $request)
{
try {
$payload = $request->validated();
$template = IdentityMessageTemplate::findOrFail($payload['template_id']);
$template = $this->templateService->findOrFailById((int) $payload['template_id']);
$rendered = $this->templateService->getPreview(
$template,
$payload['data'] ?? [],
@@ -104,7 +107,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 템플릿을 시더 기본값으로 복원.
*
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function reset(IdentityMessageTemplate $template)
{
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 본문 미사용 base Request 면제 주석·PHPDoc 만 추가 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -27,6 +29,9 @@ class NotificationController extends AdminBaseController
/**
* 알림 목록을 조회합니다.
*
* @param NotificationIndexRequest $request 알림 목록 조회 요청 (page/per_page)
* @return JsonResponse 알림 목록 응답
*/
public function index(NotificationIndexRequest $request): JsonResponse
{
@@ -51,7 +56,11 @@ class NotificationController extends AdminBaseController
/**
* 미읽음 알림 수를 반환합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 미읽음 알림 수 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function unreadCount(Request $request): JsonResponse
{
try {
@@ -70,7 +79,12 @@ class NotificationController extends AdminBaseController
/**
* 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
@@ -93,6 +107,9 @@ class NotificationController extends AdminBaseController
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param NotificationBatchReadRequest $request 일괄 읽음 처리 요청
* @return JsonResponse 일괄 읽음 처리 결과 응답
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
@@ -114,7 +131,11 @@ class NotificationController extends AdminBaseController
/**
* 모든 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAllAsRead(Request $request): JsonResponse
{
try {
@@ -133,7 +154,11 @@ class NotificationController extends AdminBaseController
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroyAll(Request $request): JsonResponse
{
try {
@@ -152,7 +177,12 @@ class NotificationController extends AdminBaseController
/**
* 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroy(Request $request, string $id): JsonResponse
{
try {
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 본문 미사용 base Request 면제 주석·PHPDoc 만 추가 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Base\AuthBaseController;
@@ -27,6 +29,9 @@ class NotificationController extends AuthBaseController
/**
* 알림 목록을 조회합니다.
*
* @param NotificationIndexRequest $request 알림 목록 조회 요청 (page/per_page)
* @return JsonResponse 알림 목록 응답
*/
public function index(NotificationIndexRequest $request): JsonResponse
{
@@ -51,7 +56,11 @@ class NotificationController extends AuthBaseController
/**
* 미읽음 알림 수를 반환합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 미읽음 알림 수 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function unreadCount(Request $request): JsonResponse
{
try {
@@ -70,7 +79,12 @@ class NotificationController extends AuthBaseController
/**
* 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
@@ -93,6 +107,9 @@ class NotificationController extends AuthBaseController
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param NotificationBatchReadRequest $request 일괄 읽음 처리 요청
* @return JsonResponse 일괄 읽음 처리 결과 응답
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
@@ -114,7 +131,11 @@ class NotificationController extends AuthBaseController
/**
* 모든 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAllAsRead(Request $request): JsonResponse
{
try {
@@ -133,7 +154,11 @@ class NotificationController extends AuthBaseController
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroyAll(Request $request): JsonResponse
{
try {
@@ -152,7 +177,12 @@ class NotificationController extends AuthBaseController
/**
* 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroy(Request $request, string $id): JsonResponse
{
try {
@@ -2,10 +2,13 @@
namespace App\Http\Requests\Admin;
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 검증 안내의 항목 표시명 로케일 정합만 수정 (attributes 우선순위)
use App\Extension\HookManager;
use App\Extension\PluginManager;
use App\Services\DriverRegistryService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Lang;
/**
* 플러그인 설정 업데이트 요청 검증
@@ -185,6 +188,17 @@ class UpdatePluginSettingsRequest extends FormRequest
$attributes = [];
foreach ($plugin->getSettingsSchema() as $field => $config) {
// 플러그인이 요청 시점에 번역선(validation.attributes.{field})을 현재 로케일로
// 등록했다면 그것이 우선이다. 스키마 label 은 보통 ko/en 만 들고 있어, 여기서
// 폴백 라벨을 세우면 언어팩/리스너가 제공한 로케일별 라벨(ja 등)을 customAttributes
// 우선순위로 덮는다 — 검증 안내의 항목 이름만 다른 언어로 남는 회귀가 된다.
$translationKey = "validation.attributes.{$field}";
if (Lang::has($translationKey, $locale, false)) {
$attributes[$field] = Lang::get($translationKey, [], $locale);
continue;
}
$label = $config['label'] ?? null;
if (is_array($label)) {
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
/**
@@ -67,6 +68,29 @@ class IdentityMessageTemplateService
);
}
/**
* ID 로 템플릿을 조회하며, 없으면 예외를 발생시킵니다.
*
* 컨트롤러가 모델을 직접 조회하지 않도록 하는 Service 경유 접근자입니다
* (Controller → Service → Repository 계층 규정).
*
* @param int $id 템플릿 ID
* @return IdentityMessageTemplate 템플릿 모델
*
* @throws ModelNotFoundException 템플릿이 없는 경우
*/
public function findOrFailById(int $id): IdentityMessageTemplate
{
$template = $this->repository->findById($id);
if (! $template) {
throw (new ModelNotFoundException)
->setModel(IdentityMessageTemplate::class, [$id]);
}
return $template;
}
/**
* 템플릿 수정.
*
@@ -4,10 +4,11 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.3] - 2026-08-12
## [1.0.3] - 2026-08-17
### Added
- 답글 삭제 정책(reply_delete_policy) 설정 라벨·옵션·차단 안내 문구의 일본어 번역을 추가했습니다.
- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
- 비공개(비밀) 게시글 목록 마스킹 시 표시되는 제목 플레이스홀더(`secret_post_title`)의 일본어 번역을 추가했습니다.
- 첨부 영구 정리 설정의 라벨·도움말과 검증 항목명 일본어 번역을 추가했습니다 (`admin/settings.attachment_purge_*`, `validation.attributes.attachment_settings.*`) — 게시판 환경설정 > 기본 설정의 새 항목과 저장 시 오류 안내가 일본어 로케일에서 표시됩니다.
@@ -71,6 +71,7 @@ return [
'use_comment' => 'コメント使用',
'use_reply' => '返信使用',
'max_reply_depth' => '最大返信深度',
'reply_delete_policy' => '返信の削除方式',
'use_report' => '通報使用',
'use_file_upload' => 'ファイルアップロード使用',
'max_file_size' => '最大ファイルサイズ',
@@ -6,6 +6,10 @@ return [
'enabled' => '選択使用',
'always' => '必須使用',
],
'reply_delete_policy' => [
'block' => '削除をブロック(返信がある間は元の投稿を削除できません)',
'cascade' => '一緒に削除(元の投稿の削除時に返信も削除されます)',
],
'order_direction' => [
'asc' => '昇順',
'desc' => '降順',
@@ -219,6 +219,9 @@ return [
'notice_not_allowed' => 'お知らせには返信を作成することはできません。',
],
'reply_not_allowed' => 'この掲示板は返信機能が無効化されています。',
'delete' => [
'has_replies' => '返信が付いた投稿は削除できません。先に返信を削除してください。',
],
'status' => [
'in' => '無効な投稿ステータスです。',
],
@@ -256,6 +259,7 @@ return [
'basic_defaults.use_comment' => 'コメント使用有無',
'basic_defaults.use_reply' => '返信使用有無',
'basic_defaults.max_reply_depth' => '最大返信深さ',
'basic_defaults.reply_delete_policy' => '返信の削除方式',
'basic_defaults.max_comment_depth' => '最大コメント深さ',
'basic_defaults.comment_order' => 'コメント並べ替え',
'basic_defaults.show_view_count' => '閲覧数表示',
@@ -339,6 +343,7 @@ return [
'override_values.per_page' => 'ページあたりの投稿数',
'override_values.per_page_mobile' => 'モバイルページあたりの投稿数',
'override_values.max_reply_depth' => '最大返信深さ',
'override_values.reply_delete_policy' => '返信の削除方式',
'override_values.max_comment_depth' => '最大コメント深さ',
'override_values.min_title_length' => '最小タイトル長',
'override_values.max_title_length' => '最大タイトル長',
@@ -110,6 +110,14 @@
"label": "返信投稿の最大深さ",
"hint": "{{min}}~{{max}}の範囲内の値を入力してください"
},
"reply_delete_policy": {
"label": "返信の削除方式",
"hint": "返信が付いた投稿を削除するときの動作を設定します",
"options": {
"block": "削除をブロック",
"cascade": "一緒に削除"
}
},
"max_comment_depth": {
"label": "返信への返信の最大深さ",
"hint": "{{min}}~{{max}}の範囲内の値を入力してください。0に設定すると返信への返信機能が無効化されます。"
@@ -80,6 +80,7 @@
"max_content_length": "最大投稿文字数",
"use_reply": "回答投稿の使用",
"max_reply_depth": "回答投稿の最大深さ",
"reply_delete_policy": "返信の削除方式",
"use_comment": "コメントの使用",
"comment_order": "コメント並べ替え順序",
"min_comment_length": "最小コメント文字数",
@@ -168,6 +169,7 @@
"max_content_length": "投稿本文の最大文字数を設定します ({{min}}~{{max}})",
"use_reply": "ON に設定する必要があります。新規掲示板に返信投稿機能が適用されます。",
"max_reply_depth": "返信投稿の最大ネスト深さを設定します ({{min}}~{{max}})",
"reply_delete_policy": "返信が付いた投稿を削除するときの動作を設定します",
"blocked_keywords": "登録されたキーワードを含む投稿/コメントは作成がブロックされます",
"use_comment": "ON に設定する必要があります。新規掲示板にコメント機能が適用されます。以下のコメント詳細設定も一緒に適用されます。",
"comment_order": "コメントのデフォルト並べ替え方向を設定します",
@@ -203,6 +205,10 @@
"enabled": "選択可能",
"always": "常に非公開"
},
"reply_delete_policy": {
"block": "削除をブロック",
"cascade": "一緒に削除"
},
"order_by": {
"created_at": "最新順",
"view_count": "閲覧数順",
@@ -286,6 +292,7 @@
"use_comment": "コメント使用",
"use_reply": "返信コメント使用",
"max_reply_depth": "返信コメント最大深さ",
"reply_delete_policy": "返信の削除方式",
"max_comment_depth": "返信の返信最大深さ",
"comment_order": "コメント並べ替え順序",
"show_view_count": "閲覧数表示",
@@ -4,11 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.1] - 2026-08-12
## [1.1.1] - 2026-08-17
### Added
- 확정 불가 상태의 주문 옵션 구매확정 시도 안내(`exceptions.order_option_not_confirmable`)의 일본어 번역을 추가했습니다.
- 이미 답변된 상품 문의에 답변을 재등록할 때의 거절 안내(`inquiries.reply_already_exists`) 일본어 번역을 추가했습니다.
- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`).
- 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다.
- 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
@@ -1100,6 +1100,7 @@ return [
'board_changed' => '掲示板設定が変更されたため、該当する投稿が見つかりません。管理者にお問い合わせください。',
'reply_created' => '返信が登録されました。',
'reply_failed' => '返信登録に失敗しました。',
'reply_already_exists' => 'すでに登録された回答があります。既存の回答を修正または削除してから再度登録してください。',
'reply_updated' => '返信が編集されました。',
'reply_update_failed' => '回答の編集に失敗しました。',
'reply_deleted' => '回答が削除されました。',
@@ -4,10 +4,11 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.1] - 2026-08-14
## [1.1.1] - 2026-08-17
### Added
- 에스크로 배송등록·구매거절확인 검증 안내 문구(운송장번호·택배사·기본 확인자명)의 일본어 번역을 추가했습니다.
- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
## [1.1.0] - 2026-08-10
@@ -40,6 +40,11 @@ return [
'cash_receipt' => [
'provider_name' => 'KGイニシス',
],
'escrow' => [
'invoice_required' => '送り状番号を入力してください。',
'courier_required' => '配送業者を選択してください。',
'default_confirmer' => '管理者',
],
'cbt_cvs' => [
'simulate_success' => '入金シミュレーションが完了しました。',
'simulate_failed' => '入金シミュレーションに失敗しました。',
@@ -4,10 +4,11 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.3] - 2026-08-14
## [1.0.3] - 2026-08-17
### Added
- 에스크로 배송등록 검증 안내 문구(운송장번호·택배사)의 일본어 번역을 추가했습니다.
- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
- 결제 취소에 필요한 서버 IP 등록 안내의 일본어 번역을 추가했습니다 — 등록이 필요한 이유, 상점 관리자에서의 등록 경로, 등록할 IP 확인·복사 안내가 일본어로 표시됩니다.
- 관리자 결제 상세의 결제 상태·취소 금액·취소 일시·환불 상태·환불 번호 항목명과, 취소 완료·부분 취소 상태 표시의 일본어 번역을 추가했습니다.
@@ -6,6 +6,10 @@ return [
'default_reason' => '購入者返金リクエスト',
'in_progress' => 'NHN KCP 返金はすでに処理中です。',
],
'escrow' => [
'invoice_required' => '送り状番号を入力してください。',
'courier_required' => '配送業者を選択してください。',
],
'errors' => [
'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。',
'wsdl_missing' => 'KCP WSDL ファイルがありません: :file',
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.3] - 2026-08-17
### Added
- 가상계좌 환불 입력 필드 표시명(거래 ID·주문번호·취소 금액·취소 사유·환불 계좌번호·은행코드·예금주)의 일본어 번역을 추가했습니다.
### Removed
- 원본 플러그인에서 삭제된 가상계좌 환불 필수값 안내 문구(`errors.vbank_refund_required_fields`)를 함께 정리했습니다 — 개별 필드 검증 메시지로 대체되었습니다.
## [1.0.2] - 2026-08-10
### Changed
@@ -6,7 +6,6 @@ return [
'order_not_found' => '注文が見つかりません。',
'invalid_request' => '無効なリクエストです。',
'invalid_amount' => 'リクエスト金額が有効ではありません。',
'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。',
'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。',
'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount)',
'vbank_refund_order_not_found' => '返金する注文が見つかりません。',
@@ -21,4 +20,13 @@ return [
'defaults' => [
'vbank_refund_msg' => '仮想口座返金',
],
'fields' => [
'tid' => '取引ID(TID)',
'moid' => '注文番号',
'cancel_amt' => 'キャンセル金額',
'cancel_msg' => 'キャンセル事由',
'refund_acct_no' => '返金口座番号',
'refund_bank_cd' => '返金銀行コード',
'refund_acct_nm' => '返金口座名義人',
],
];
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
},
"version": "1.0.2",
"version": "1.0.3",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-pay_nicepayments",
+11 -1
View File
@@ -4,10 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-08-12
## [1.0.4] - 2026-08-17
### Security
- 게시글·댓글 삭제와 게시글 수정 시 비회원 확인용 비밀번호/검증 토큰 입력에 형식 검증을 추가해 비정상 형태(배열 주입 등)의 요청을 차단합니다.
- 관리자 게시판 목록 조회의 정렬 기준과 페이지 크기를 허용된 값으로만 제한했습니다.
- 비밀글의 내용이 열람 권한 없이 새어 나가던 경로를 모두 막았습니다. 게시글 상세 화면은 비밀글 내용을 가렸지만, 상품 문의 목록·비밀글의 댓글 목록·비밀글의 첨부파일(다운로드/미리보기)은 게시글의 비밀 여부를 확인하지 않아 주소만 알면 원문·첨부를 볼 수 있었습니다. 이제 이 경로 전부에서 작성자 본인 또는 게시판 관리 권한(비밀글 열람)을 가진 요청에만 내용을 제공하고, 그 외에는 내용·제목·답변·첨부를 가립니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
- 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914)
- 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919)
@@ -19,6 +22,7 @@
### Added
- 답글이 달린 글의 삭제 방식을 게시판마다 선택할 수 있습니다. "함께 삭제"(기본값)는 글을 삭제하면 달려 있던 답글도 함께 휴지통으로 이동하고, 글을 복원하면 그때 함께 지워진 답글만 같이 복원됩니다. "삭제 차단"은 답글이 남아 있는 동안 원글 삭제를 막고 사유를 안내합니다. 사용자가 직접 지운 답글은 원글을 복원해도 되살아나지 않습니다.
- 글쓰기 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다.
- 삭제한 첨부의 파일을 보존기간이 지난 뒤 완전히 파기하는 기능이 추가되었습니다. 게시판 환경설정 > 기본 설정에서 켤 수 있으며(기본 꺼짐), 보존기간은 휴지통 복원 가능 기간과 같게 유지하시면 됩니다.
@@ -32,6 +36,7 @@
- 통합검색에서 게시글 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 샘플 게시글 생성 도구가 데이터베이스 접두어를 기본값이 아닌 값으로 설치한 사이트에서 오류가 나던 문제를 수정했습니다. (#101 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 관리자 게시글 작성 화면의 안내 아이콘이 의도한 크기와 다르게 보이던 문제를 수정했습니다.
- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
- 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다.
@@ -40,6 +45,11 @@
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
- 댓글 목록을 불러올 때 댓글마다 원글을 반복 조회하던 비효율을 제거해 응답을 더 빠르게 했습니다. 댓글이 많은 글일수록 개선 폭이 큽니다.
- 답글이 달린 글을 삭제해도 답글이 지워지지 않아, 목록에는 보이지 않으면서 주소로는 열리는 상태로 남던 문제를 수정했습니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 이커머스 문의 연동: 이미 답변이 있는 문의 글에는 연동 경로로도 답변 글이 중복 생성되지 않도록 방어를 추가했습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 게시판을 삭제할 때 다른 기능(상품 문의 연동 등)이 그 게시판의 글과 연결해 둔 데이터를 함께 정리할 수 있도록, 삭제되는 글 목록을 연동 기능에 알려줍니다.
- 신고 목록의 동작하지 않는 정렬 파라미터를 정리했습니다.
- 관리자 화면(게시글 작성·환경설정 저장·기본값 일괄 적용·메일/알림 템플릿·SEO/스팸 캐시 정리·본인인증 정책)에서 저장·실행이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
## [1.0.3] - 2026-08-10
@@ -15,6 +15,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* boards 테이블에 답글 삭제 정책 컬럼을 추가합니다.
*
* 답글이 달린 글을 삭제할 때의 동작을 게시판별로 선택합니다.
* - 'cascade' (기본값): 답글도 함께 소프트 삭제하고, 복원 시 연쇄분만 되살립니다.
* - 'block': 살아 있는 답글이 있는 동안 원글 삭제를 차단합니다.
*
* NOT NULL + default 라 ALTER 시 기존 행이 자동으로 'cascade' 로 채워집니다 —
* 지금까지 성공하던 삭제(신고 조치·관리자 삭제)가 갑자기 차단되지 않습니다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('boards') || Schema::hasColumn('boards', 'reply_delete_policy')) {
return;
}
Schema::table('boards', function (Blueprint $table) {
$table->string('reply_delete_policy', 20)
->default('cascade')
->comment('답글 삭제 정책 (block: 답글 있으면 삭제 차단, cascade: 함께 소프트 삭제)')
->after('max_reply_depth');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('boards') || ! Schema::hasColumn('boards', 'reply_delete_policy')) {
return;
}
Schema::table('boards', function (Blueprint $table) {
$table->dropColumn('reply_delete_policy');
});
}
};
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* board_posts.trigger_type enum 에 'cascade' 값을 추가합니다.
* 원글 삭제 시 함께 지워지는 답글 트리를 'cascade' 로 마킹하여, 원글 복원 시
* 사용자가 직접 지운 답글('user')과 구분해 선별 복원하기 위함입니다
* (댓글 2026_06_26_000001 / 첨부 2026_06_26_000002 와 동일 규약).
*/
public function up(): void
{
$prefix = DB::getTablePrefix();
// enum 에 'cascade' 추가 (조치 주체: 원글 삭제 연쇄)
DB::statement(
"ALTER TABLE {$prefix}board_posts MODIFY COLUMN trigger_type "
."ENUM('report', 'admin', 'system', 'auto_hide', 'user', 'cascade') "
."NOT NULL DEFAULT 'admin' "
."COMMENT '조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제, cascade: 원글 삭제 연쇄)'"
);
}
/**
* Reverse the migrations.
*
* enum 축소 전, 잔존하는 'cascade' 행을 'admin' 으로 치환해야 롤백이 깨지지 않습니다.
* (enum 에 없는 값이 데이터에 남아 있으면 MODIFY COLUMN 이 실패)
*/
public function down(): void
{
$prefix = DB::getTablePrefix();
// 잔존 cascade 행을 admin 으로 치환 (enum 축소 안전 처리)
DB::table('board_posts')
->where('trigger_type', 'cascade')
->update(['trigger_type' => 'admin']);
DB::statement(
"ALTER TABLE {$prefix}board_posts MODIFY COLUMN trigger_type "
."ENUM('report', 'admin', 'system', 'auto_hide', 'user') "
."NOT NULL DEFAULT 'admin' "
."COMMENT '조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제)'"
);
}
};
@@ -991,10 +991,11 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write\|sirsoft-board.{slug}.admin.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 게시판의 답글 삭제 정책(`reply_delete_policy`)이 `block` 이고 대상 글에 살아 있는 답글이 있는 경우 (`답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.` — `sirsoft-board::validation.post.delete.has_replies`) |
<!-- @generated:end -->
**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다.
**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다. 삭제 동작은 게시판의 답글 삭제 정책(`reply_delete_policy`)을 따릅니다 — `cascade`(기본)에서는 원글 삭제 시 살아 있는 답글 트리(및 그 답글들의 댓글·첨부)가 함께 소프트 삭제되고 이후 원글을 복원하면 연쇄 삭제분(`trigger_type='cascade'`)만 선택 복원되며, `block` 에서는 살아 있는 직계 답글이 있으면 `before_delete` 훅 발화 전에 차단되어(부수효과 없음) 422 를 반환합니다.
### GET /api/modules/sirsoft-board/admin/board/{slug}/posts/{id}
@@ -25,7 +25,14 @@
**요청 파라미터**
_요청 파라미터 없음._
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
| per_page | query | integer | 아니오 | min 1, max 999 | 페이지당 항목 수 (게시판은 운영자 등록 설정성 테이블 — 전체 목록 셀렉트 소비처의 전량 조회를 수용) |
| type | query | string | 아니오 | max 50 | 게시판 타입 슬러그 필터 (basic, gallery 등) |
| search | query | string | 아니오 | max 100 | 검색어 (게시판명/슬러그 부분 일치) |
| sort_by | query | string | 아니오 | `id`, `slug`, `type`, `is_active`, `posts_count`, `comments_count`, `created_at`, `updated_at` | 정렬 기준 컬럼 (닫힌 집합 — `name` 은 다국어 JSON 컬럼이라 정렬 대상에서 제외) |
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (대문자 `ASC`/`DESC` 로 보내도 검증 전 소문자로 정규화되어 수용) |
**요청 예시**
@@ -102,10 +109,11 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) — `sort_by` 가 허용 컬럼 목록 밖이거나, `sort_order` 가 `asc`/`desc` 가 아니거나, `per_page` 가 1~999 범위를 벗어난 경우 등 (`IndexBoardRequest` 검증) |
<!-- @generated:end -->
**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, 요청의 필터/페이징 파라미터를 그대로 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다.
**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, `IndexBoardRequest` 로 검증된 필터/정렬/페이징 파라미터만 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 정렬 컬럼(`sort_by`)과 페이지 크기(`per_page` 1~999)는 닫힌 집합으로 제한되어 허용값 밖 요청은 422 로 거부됩니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다.
### POST /api/modules/sirsoft-board/admin/boards
@@ -150,6 +158,7 @@ HTTP/1.1 200
| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 |
| permissions | body | array | 아니오 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 미지정 시 Service가 Manager/Step 역할을 주입) |
| max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 |
| reply_delete_policy | body | string | 예 | `block`, `cascade` | 답글 달린 글의 삭제 정책 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 미전송 시 검증 전 기본값 `cascade` 로 채워지며, 허용값 밖이면 422 |
| max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 |
| notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 |
| notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 |
@@ -213,6 +222,7 @@ Content-Type: application/json
"예시값"
],
"max_reply_depth": 1,
"reply_delete_policy": "cascade",
"max_comment_depth": 1,
"notify_admin_on_post": true,
"notify_author": true,
@@ -244,6 +254,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시판 = `BoardResource`,
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 |
| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 |
@@ -301,6 +312,7 @@ HTTP/1.1 201
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -390,6 +402,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 기본값 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| show_view_count | boolean | `true` | 조회수 노출 |
@@ -445,6 +458,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -755,6 +769,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
@@ -812,6 +827,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -973,6 +989,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
@@ -1030,6 +1047,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1267,6 +1285,7 @@ HTTP/1.1 200
| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 |
| permissions | body | array | 예 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 각 권한을 all 또는 특정 역할에 부여) |
| max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 |
| reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 답글 달린 글의 삭제 정책 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 미전송 시 기존값 유지, 허용값 밖이면 422 |
| max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 |
| notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 |
| notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 |
@@ -1329,6 +1348,7 @@ Content-Type: application/json
"예시값"
],
"max_reply_depth": 1,
"reply_delete_policy": "block",
"max_comment_depth": 1,
"notify_admin_on_post": true,
"notify_author": true,
@@ -1360,6 +1380,7 @@ _단건 응답: `data` 객체의 필드 (수정된 게시판 = `BoardResource`,
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 |
| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC, DESC) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 |
@@ -1417,6 +1438,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1980,6 +2002,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
@@ -2037,6 +2060,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -3325,6 +3349,9 @@ HTTP/1.1 200
| --- | --- | --- | --- | --- | --- |
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
| id | path | string | 예 | — | 대상 리소스의 식별자 |
| password | body | string | 아니오 | min 4 | 비회원 게시글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4자 이상)은 `DestroyPostRequest` 가 검증하며, 배열 주입(`password[]=x`) 등 형식 위반은 422. 소유권 판정 자체는 컨트롤러(`canModifyPost`)가 수행 |
| verification_token | body | string | 아니오 | max 255 | 비회원 게시글 삭제 시 소유권 확인용 검증 토큰 (비밀번호 검증 후 발급). 문자열 형식 위반 시 422 |
| force_delete | body | boolean | 아니오 | — | 불리언 플래그 (형식 검증만 수행 — 불리언으로 해석 불가한 값은 422) |
**요청 예시**
@@ -3359,11 +3386,12 @@ HTTP/1.1 200
| --- | --- | --- |
| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write\|sirsoft-board.{slug}.manager`)이 없거나, 작성자 본인·게시판 관리자·비회원 글 검증(토큰/비밀번호) 중 어느 조건도 만족하지 않는 경우 (`게시글 삭제 권한이 없습니다.`) |
| 404 | Not Found | 활성 게시판 또는 게시글이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터(`password`/`verification_token`/`force_delete`)가 형식 검증을 위반한 경우 (`error.errors` 에 필드별 메시지) / 게시판의 답글 삭제 정책(`reply_delete_policy`)이 `block` 이고 살아 있는 답글이 있는 경우 (`답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.` — `sirsoft-board::validation.post.delete.has_replies`) |
| 500 | Internal Server Error | 삭제 처리 중 예외 (`게시글 삭제에 실패했습니다.`) |
<!-- @generated:end -->
**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다.
**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다. 삭제 동작은 게시판의 답글 삭제 정책(`reply_delete_policy`)을 따릅니다 — `cascade`(기본)에서는 원글 삭제 시 살아 있는 답글 트리(및 그 답글들의 댓글·첨부)가 함께 소프트 삭제되고 이후 원글을 복원하면 연쇄 삭제분이 함께 복원되며, `block` 에서는 살아 있는 답글이 있으면 어떤 부수효과도 없이 422 로 삭제가 차단됩니다.
### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id}
@@ -3630,6 +3658,8 @@ HTTP/1.1 200
| attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) |
| temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 |
| is_secret | body | boolean | 아니오 | — | 비밀글 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) |
| password | body | string | 아니오 | min 4 | 비회원 게시글 수정 시 소유권 확인용 비밀번호. 형식(문자열, 4자 이상)만 여기서 검증하며 배열 주입 등 형식 위반은 422. 소유권 판정은 컨트롤러(`canModifyPost`)가 수행 |
| verification_token | body | string | 아니오 | max 255 | 비회원 게시글 수정 시 소유권 확인용 검증 토큰 (비밀번호 검증 후 발급). 문자열 형식 위반 시 422 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`).
@@ -4346,6 +4376,7 @@ HTTP/1.1 201
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
| postId | path | string | 예 | — | 대상 post의 식별자 |
| commentId | path | string | 예 | — | 대상 comment의 식별자 |
| password | body | string | 아니오 | min 4, max 20 | 비회원 댓글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4~20자)은 `DestroyCommentRequest` 가 검증하며, 배열 주입 등 형식 위반은 422. 소유권 판정 자체는 서비스(`canDelete`)가 수행 |
**요청 예시**
@@ -4380,6 +4411,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write\|sirsoft-board.{slug}.manager`)이 없거나, 게시판의 댓글 기능이 꺼진 경우, 또는 작성자 본인·게시판 관리자·비회원 댓글 비밀번호 확인 중 어느 조건도 만족하지 않는 경우 (`댓글 삭제 권한이 없습니다.`) |
| 404 | Not Found | 게시판 또는 댓글이 없는 경우 (`댓글을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | `password` 가 형식 검증(문자열, 4~20자)을 위반한 경우 (`error.errors.password` — 배열 주입 차단) |
| 500 | Internal Server Error | 삭제 처리 중 예외 (`댓글 삭제에 실패했습니다.`) |
<!-- @generated:end -->
@@ -42,7 +42,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 답글 삭제 방식(reply_delete_policy: block/cascade), 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
| report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. |
| spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. |
| display | object | `{"date_display_format":"standard"}` | 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. |
@@ -73,6 +73,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -362,6 +363,7 @@ HTTP/1.1 200
| _tab | body | string | 아니오 | `basic_defaults`, `report_policy`, `spam_security`, `general`, `seo`, `notifications`, `notification_definitions` | 현재 편집 중인 탭을 나타내는 메타 값. 탭 단위 부분 저장의 컨텍스트를 식별하는 용도이며 설정값으로는 저장되지 않습니다. |
| notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. |
| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. |
| basic_defaults.reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 게시판 생성 시 적용될 답글 삭제 정책 기본값 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 허용값 밖이면 422 |
| report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. |
| report_policy.auto_hide_threshold | body | integer | 아니오 | min 0, max 100 | 자동 숨김 신고 수 (이 횟수 이상 신고되면 자동 숨김 처리) |
| report_policy.auto_hide_target | body | string | 아니오 | `post`, `comment`, `both` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) |
@@ -426,6 +428,7 @@ Content-Type: application/json
"basic_defaults.use_comment": true,
"basic_defaults.use_reply": true,
"basic_defaults.max_reply_depth": 1,
"basic_defaults.reply_delete_policy": "cascade",
"basic_defaults.max_comment_depth": 1,
"basic_defaults.comment_order": "ASC",
"basic_defaults.show_view_count": true,
@@ -509,7 +512,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 답글 삭제 방식(reply_delete_policy: block/cascade), 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
| report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 저장 후 갱신된 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. |
| spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 저장 후 갱신된 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. |
| display | object | `{"date_display_format":"standard"}` | 저장 후 갱신된 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. |
@@ -538,6 +541,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -794,7 +798,8 @@ HTTP/1.1 200
| fields | body | array | 예 | min 1 | 대상 게시판에 일괄 적용할 필드 목록(최소 1개). boards 테이블 컬럼(type, per_page, use_comment, allowed_extensions 등)이나 권한 필드(default_board_permissions, manager), 점(.)을 포함한 개별 권한 키(예: `posts.read`)를 허용합니다. |
| apply_all | body | boolean | 예 | — | 전체 게시판 적용 여부. true면 모든 게시판에 적용하고, false면 `board_ids`로 지정한 게시판에만 적용합니다(false 시 board_ids 필수). |
| board_ids | body | array | 아니오 | — | board 식별자 배열 |
| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. |
| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. 각 키는 게시판 생성/수정과 동일한 제한값으로 검증됩니다 (범위 밖 값은 422). |
| override_values.reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 답글 삭제 정책을 일괄 적용할 때의 재정의 값 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). `fields` 에 `reply_delete_policy` 를 포함해 함께 사용하며, 허용값 밖이면 422 |
**요청 예시**
@@ -1036,6 +1036,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": false,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1144,6 +1145,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": false,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1599,6 +1601,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"reply_delete_policy": "cascade",
"use_report": false,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -317,6 +317,30 @@ describe('admin_board_form/_tab_post.json', () => {
expect(maxReplyDepthField.if).toContain('use_reply');
});
it('reply_delete_policy_field 가 use_reply ON 조건부로 존재하고 block/cascade Select 를 렌더한다 (#573)', () => {
const policyField = findById(tabPost, 'reply_delete_policy_field');
expect(policyField).toBeDefined();
expect(policyField.if).toBeDefined();
expect(policyField.if).toContain('use_reply');
const select = findById(policyField, 'reply_delete_policy_select');
expect(select).toBeDefined();
expect(select.name).toBe('Select');
expect(select.props.name).toBe('reply_delete_policy');
const values = (select.props.options as Array<{ value: string }>).map((o) => o.value).sort();
expect(values).toEqual(['block', 'cascade']);
});
it('reply_delete_policy 라벨/힌트/옵션의 ko 다국어 키가 실재한다 (#573)', () => {
const field = (koFormLang as any).fields?.reply_delete_policy;
expect(field).toBeDefined();
expect(field.label).toBeTruthy();
expect(field.hint).toBeTruthy();
expect(field.options?.block).toBeTruthy();
expect(field.options?.cascade).toBeTruthy();
});
it('row-stack 컨테이너 section_post_fields 에 use_comment 토글이 있다', () => {
const fieldsContainer = findById(tabPost, 'section_post_fields');
expect(fieldsContainer).toBeDefined();
@@ -727,6 +727,7 @@ describe('_tab_board_settings_reply.json - 답변글 하위 탭', () => {
const fields = findFormFields(tabBoardSettingsReply);
expect(fields).toContain('basic_defaults.use_reply');
expect(fields).toContain('basic_defaults.max_reply_depth');
expect(fields).toContain('basic_defaults.reply_delete_policy');
});
it('답변글 깊이 필드가 항상 표시된다 (if 조건 없음)', () => {
@@ -734,6 +735,34 @@ describe('_tab_board_settings_reply.json - 답변글 하위 탭', () => {
expect(maxReplyDepth).toBeDefined();
expect(maxReplyDepth.if).toBeUndefined();
});
it('답글 삭제 정책 Select 가 block/cascade 옵션으로 렌더된다 (#573)', () => {
const policyField = findById(tabBoardSettingsReply, 'field_reply_delete_policy');
expect(policyField).toBeDefined();
expect(policyField.if).toBeUndefined();
const collect: any[] = [];
const walk = (node: any) => {
if (!node || typeof node !== 'object') return;
if (node.name === 'Select') collect.push(node);
for (const child of node.children ?? []) walk(child);
};
walk(policyField);
expect(collect).toHaveLength(1);
expect(collect[0].props.name).toBe('basic_defaults.reply_delete_policy');
const values = (collect[0].props.options as Array<{ value: string }>).map((o) => o.value).sort();
expect(values).toEqual(['block', 'cascade']);
});
it('답글 삭제 정책 ko 다국어 키(라벨/설명/옵션/일괄적용)가 실재한다 (#573)', () => {
const lang = koSettingsLang as any;
expect(lang.fields?.reply_delete_policy).toBeTruthy();
expect(lang.fields?.descriptions?.reply_delete_policy).toBeTruthy();
expect(lang.options?.reply_delete_policy?.block).toBeTruthy();
expect(lang.options?.reply_delete_policy?.cascade).toBeTruthy();
expect(lang.bulk_apply?.field_labels?.reply_delete_policy).toBeTruthy();
});
});
// =============================================
@@ -110,6 +110,14 @@
"label": "Max Reply Depth",
"hint": "Enter a value between {{min}} and {{max}}"
},
"reply_delete_policy": {
"label": "Reply Delete Policy",
"hint": "Set what happens when deleting a post that has replies",
"options": {
"block": "Block deletion",
"cascade": "Delete together"
}
},
"max_comment_depth": {
"label": "Max Comment Depth",
"hint": "Enter a value between {{min}} and {{max}}. Setting to 0 disables the nested comment feature."
@@ -84,6 +84,7 @@
"max_content_length": "Maximum Content Length",
"use_reply": "Enable Reply Posts",
"max_reply_depth": "Max Reply Depth",
"reply_delete_policy": "Reply Delete Policy",
"use_comment": "Enable Comments",
"comment_order": "Comment Sort Order",
"min_comment_length": "Min Comment Length",
@@ -172,6 +173,7 @@
"max_content_length": "Set the maximum character count for post content ({{min}}–{{max}})",
"use_reply": "Must be ON to apply reply post feature to new boards.",
"max_reply_depth": "Set the maximum nesting depth for replies ({{min}}–{{max}})",
"reply_delete_policy": "Set what happens when deleting a post that has replies",
"blocked_keywords": "Posts or comments containing registered keywords will be blocked",
"use_comment": "Must be ON to apply comment feature to new boards. Comment detail settings below will also apply.",
"comment_order": "Set the default sort direction for comments",
@@ -203,6 +205,10 @@
"enabled": "Optional",
"always": "Always Secret"
},
"reply_delete_policy": {
"block": "Block deletion",
"cascade": "Delete together"
},
"order_by": {
"created_at": "Latest",
"view_count": "View Count",
@@ -287,6 +293,7 @@
"use_comment": "Enable Comments",
"use_reply": "Enable Replies",
"max_reply_depth": "Max Reply Depth",
"reply_delete_policy": "Reply Delete Policy",
"max_comment_depth": "Max Comment Depth",
"comment_order": "Comment Sort Order",
"show_view_count": "Show View Count",
@@ -110,6 +110,14 @@
"label": "답변글 최대 깊이",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"reply_delete_policy": {
"label": "답변글 삭제 방식",
"hint": "답변글이 달린 글을 삭제할 때의 동작을 설정합니다",
"options": {
"block": "삭제 차단",
"cascade": "함께 삭제"
}
},
"max_comment_depth": {
"label": "대댓글 최대 깊이",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요. 0으로 설정하면 대댓글 기능이 비활성화됩니다."
@@ -84,6 +84,7 @@
"max_content_length": "최대 게시글 글자 수",
"use_reply": "답변글 사용",
"max_reply_depth": "답변글 최대 깊이",
"reply_delete_policy": "답변글 삭제 방식",
"use_comment": "댓글 사용",
"comment_order": "댓글 정렬 순서",
"min_comment_length": "최소 댓글 글자 수",
@@ -172,6 +173,7 @@
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다 ({{min}}~{{max}})",
"use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.",
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 ({{min}}~{{max}})",
"reply_delete_policy": "답변글이 달린 글을 삭제할 때의 동작을 설정합니다",
"blocked_keywords": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다",
"use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.",
"comment_order": "댓글의 기본 정렬 방향을 설정합니다",
@@ -203,6 +205,10 @@
"enabled": "선택 가능",
"always": "항상 비밀글"
},
"reply_delete_policy": {
"block": "삭제 차단",
"cascade": "함께 삭제"
},
"order_by": {
"created_at": "최신순",
"view_count": "조회수순",
@@ -287,6 +293,7 @@
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"max_reply_depth": "답변글 최대 깊이",
"reply_delete_policy": "답변글 삭제 방식",
"max_comment_depth": "대댓글 최대 깊이",
"comment_order": "댓글 정렬 순서",
"show_view_count": "조회수 표시",
@@ -227,7 +227,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.posts.messages.save_failed",
"message": "{{error.message ?? '$t:sirsoft-board.admin.posts.messages.save_failed'}}",
"icon": "triangle-exclamation"
}
}
@@ -260,7 +260,7 @@
"name": "Icon",
"props": {
"name": "lock",
"className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
"className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
@@ -294,7 +294,7 @@
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
"className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -396,7 +396,7 @@
"name": "Icon",
"props": {
"name": "circle-dot",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -523,7 +523,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.posts.messages.save_failed",
"message": "{{error.message ?? '$t:sirsoft-board.admin.posts.messages.save_failed'}}",
"icon": "triangle-exclamation"
}
}
@@ -697,7 +697,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.save_error"
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.save_error'}}"
}
}
]
@@ -489,6 +489,70 @@
}
]
},
{
"id": "reply_delete_policy_field",
"type": "basic",
"name": "Div",
"if": "{{_local.form.use_reply}}",
"props": {
"className": ""
},
"children": [
{
"id": "reply_delete_policy_label",
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.label"
}
]
},
{
"id": "reply_delete_policy_select",
"type": "composite",
"name": "Select",
"props": {
"name": "reply_delete_policy",
"options": [
{
"value": "cascade",
"label": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.options.cascade"
},
{
"value": "block",
"label": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.options.block"
}
],
"disabled": "{{_computed.isReadOnly}}",
"className": ""
}
},
{
"id": "reply_delete_policy_hint",
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.hint"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.reply_delete_policy}}",
"props": {
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.reply_delete_policy?.[0] ?? ''}}"
}
]
},
{
"id": "use_comment_toggle",
"type": "basic",
@@ -525,7 +525,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.error"
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.bulk_apply_modal.error'}}"
}
}
]
@@ -633,7 +633,7 @@
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.mail_templates.save_error",
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.mail_templates.save_error'}}",
"type": "error"
}
}
@@ -969,7 +969,7 @@
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.notification_definitions.save_error",
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.notification_definitions.save_error'}}",
"type": "error"
}
}
@@ -212,6 +212,91 @@
]
}
]
},
{
"id": "field_reply_delete_policy",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-body font-medium flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('reply_delete_policy')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('reply_delete_policy') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'reply_delete_policy') : [...(_local.bulkApplyFields ?? []), 'reply_delete_policy']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.reply_delete_policy"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.reply_delete_policy"
}
]
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.reply_delete_policy",
"options": [
{
"value": "cascade",
"label": "$t:sirsoft-board.admin.settings.options.reply_delete_policy.cascade"
},
{
"value": "block",
"label": "$t:sirsoft-board.admin.settings.options.reply_delete_policy.block"
}
],
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
}
]
}
@@ -569,7 +569,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:admin.identity.policies.toggle_failed"
"message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}"
}
},
{
@@ -906,7 +906,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:admin.identity.policies.toggle_failed"
"message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}"
}
},
{
@@ -620,7 +620,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error"
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}"
}
}
]
@@ -637,7 +637,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error"
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}"
}
}
]
@@ -654,7 +654,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error"
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}"
}
}
]
@@ -336,7 +336,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.fields.clear_cache_error"
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.fields.clear_cache_error'}}"
}
}
]
@@ -0,0 +1,80 @@
<?php
namespace Modules\Sirsoft\Board\Enums;
/**
* 답글 달린 글의 삭제 정책
*
* 답글(parent_id 자식 글)이 남아 있는 글을 삭제하려 할 때의 동작을 게시판별로 정의합니다.
* 기본값은 Cascade — 댓글·첨부가 이미 연쇄 소프트 삭제되는 기존 의미론과 일치하며,
* 복원 시 연쇄분만 선택 복원되어 되돌릴 수 있습니다.
*/
enum ReplyDeletePolicy: string
{
/**
* 답글이 살아 있는 동안 원글 삭제 차단
*/
case Block = 'block';
/**
* 원글 삭제 시 답글도 함께 소프트 삭제 (기본값)
*/
case Cascade = 'cascade';
/**
* 모든 값 배열 반환
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 번역 키를 반환합니다.
*
* @return string 번역 키
*/
public function labelKey(): string
{
return 'sirsoft-board::enums.reply_delete_policy.'.$this->value;
}
/**
* 다국어 라벨 반환
*
* @return string 번역된 라벨
*/
public function label(): string
{
return __($this->labelKey());
}
/**
* 모든 값을 배열로 반환 (value, label 포함)
*
* @return array<array{value: string, label: string}>
*/
public static function toArray(): array
{
return array_map(
fn (self $item) => [
'value' => $item->value,
'label' => $item->label(),
],
self::cases()
);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Sirsoft\Board\Exceptions;
use Exception;
/**
* 답글 보유 글 삭제 차단 예외
*
* 게시판 설정 `reply_delete_policy` 가 'block' 인 게시판에서, 살아 있는 답글이
* 달린 글을 삭제하려 할 때 발생합니다. 판정은 before_delete 훅 발화 전에 수행되어
* 차단 시 부수효과가 전혀 남지 않습니다.
*
* 훅 경유 삭제(이커머스 문의 연동 등 `cascade_replies` 옵션 전달)는 시스템 생성
* 답글을 함께 정리해야 하므로 이 정책의 적용을 받지 않습니다.
*/
class PostHasRepliesException extends Exception
{
public function __construct()
{
parent::__construct(__('sirsoft-board::validation.post.delete.has_replies'));
}
/**
* 다국어 메시지 키를 반환합니다.
*
* @return string 다국어 메시지 키
*/
public function getMessageKey(): string
{
return 'sirsoft-board::validation.post.delete.has_replies';
}
/**
* 메시지 치환 파라미터를 반환합니다.
*
* @return array<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return [];
}
}
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Board\Exceptions\MenuAlreadyExistsException;
use Modules\Sirsoft\Board\Http\Requests\Admin\IndexBoardRequest;
use Modules\Sirsoft\Board\Http\Requests\StoreBoardRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateBoardRequest;
use Modules\Sirsoft\Board\Http\Resources\BoardCollection;
@@ -46,14 +47,13 @@ class BoardController extends AdminBaseController
/**
* 게시판 목록을 조회합니다.
*
* @param Request $request HTTP 요청
* @param IndexBoardRequest $request 목록 조회 요청 (정렬 컬럼·페이지 크기 화이트리스트 검증)
* @return JsonResponse 게시판 목록 응답
*/
// audit:allow controller-base-request-injection reason: GET 목록 조회. all()로 필터·페이징 파라미터만 읽음 (검증 불필요)
public function index(Request $request): JsonResponse
public function index(IndexBoardRequest $request): JsonResponse
{
try {
$boards = $this->boardService->getBoards($request->all());
$boards = $this->boardService->getBoards($request->validated());
// BoardCollection의 withPermissions 사용
$collection = new BoardCollection($boards);
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException;
use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormDataRequest;
use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormMetaRequest;
@@ -315,6 +316,9 @@ class PostController extends AdminBaseController
'sirsoft-board::messages.posts.delete_success',
new PostResource($deletedPost)
);
} catch (PostHasRepliesException $e) {
// 답글 삭제 정책(block): 살아있는 답글이 있어 삭제 거부 — 입력(대상 선택) 문제이므로 422
return $this->error($e->getMessageKey(), 422);
} catch (ModelNotFoundException $e) {
throw new PostNotFoundException($id);
} catch (AccessDeniedHttpException $e) {
@@ -70,7 +70,6 @@ class ReportController extends AdminBaseController
'board_id' => $request->input('board_id'),
'reported_at_from' => $request->input('reported_at_from'),
'reported_at_to' => $request->input('reported_at_to'),
'sort_by' => $request->input('sort_by'),
'sort_order' => $request->input('sort_order'),
];
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException;
use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException;
use Modules\Sirsoft\Board\Http\Requests\DestroyCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest;
@@ -221,12 +222,13 @@ class CommentController extends PublicBaseController
/**
* 댓글을 삭제합니다.
*
* @param DestroyCommentRequest $request 댓글 삭제 요청 (비회원 password 형식 검증)
* @param string $slug 게시판 slug
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return JsonResponse 댓글 삭제 결과 응답
*/
public function destroy(string $slug, int $postId, int $commentId): JsonResponse
public function destroy(DestroyCommentRequest $request, string $slug, int $postId, int $commentId): JsonResponse
{
try {
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
@@ -242,8 +244,8 @@ class CommentController extends PublicBaseController
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $commentId, $postId);
// 비회원인 경우 password 파라미터 필요
$password = request()->input('password');
// 비회원인 경우 password 파라미터 필요 (형식 검증은 DestroyCommentRequest — 배열 주입 422 차단)
$password = $request->validated('password');
// 권한 확인 (Service에서 처리)
$canDelete = $this->commentService->canDelete(
@@ -17,7 +17,9 @@ use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\SecretMode;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException;
use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Http\Requests\User\DestroyPostRequest;
use Modules\Sirsoft\Board\Http\Requests\User\StorePostRequest;
use Modules\Sirsoft\Board\Http\Requests\User\UpdatePostRequest;
use Modules\Sirsoft\Board\Http\Requests\User\VerifyGuestPasswordRequest;
@@ -480,13 +482,15 @@ class PostController extends PublicBaseController
/**
* 게시글을 삭제합니다.
*
* @param Request $request HTTP 요청
* 비회원 소유권 확인용 password/verification_token 형식 검증은 DestroyPostRequest 가
* 담당한다 (배열 주입 422 차단). 소유권 판정 자체는 canModifyPost 가 수행한다.
*
* @param DestroyPostRequest $request 게시글 삭제 요청
* @param string $slug 게시판 슬러그
* @param string|int $id 게시글 ID
* @return JsonResponse 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요)
public function destroy(Request $request, string $slug, string|int $id): JsonResponse
public function destroy(DestroyPostRequest $request, string $slug, string|int $id): JsonResponse
{
$id = (int) $id;
@@ -509,6 +513,9 @@ class PostController extends PublicBaseController
$this->postService->deletePost($slug, $id, 'user');
return $this->success('sirsoft-board::messages.posts.delete_success');
} catch (PostHasRepliesException $e) {
// 답글 삭제 정책(block): 살아있는 답글이 있어 삭제 거부 — 입력(대상 선택) 문제이므로 422
return $this->error($e->getMessageKey(), 422);
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (ModelNotFoundException $e) {
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Models\Board;
@@ -30,6 +31,7 @@ class BulkApplySettingsRequest extends FormRequest
'use_comment',
'use_reply',
'max_reply_depth',
'reply_delete_policy',
'max_comment_depth',
'comment_order',
'show_view_count',
@@ -117,6 +119,7 @@ class BulkApplySettingsRequest extends FormRequest
'override_values.per_page' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"],
'override_values.per_page_mobile' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"],
'override_values.max_reply_depth' => ['nullable', 'integer', "min:{$limits['max_reply_depth_min']}", "max:{$limits['max_reply_depth_max']}"],
'override_values.reply_delete_policy' => ['nullable', 'string', Rule::in(ReplyDeletePolicy::values())],
'override_values.max_comment_depth' => ['nullable', 'integer', "min:{$limits['max_comment_depth_min']}", "max:{$limits['max_comment_depth_max']}"],
'override_values.min_title_length' => ['nullable', 'integer', "min:{$limits['min_title_length_min']}", "max:{$limits['min_title_length_max']}"],
'override_values.max_title_length' => ['nullable', 'integer', "min:{$limits['max_title_length_min']}", "max:{$limits['max_title_length_max']}"],
@@ -0,0 +1,58 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
/**
* 관리자 게시판 목록 조회 요청을 검증합니다.
*
* 정렬 컬럼·페이지 크기를 닫힌 집합으로 제한합니다 — 요청 값이 그대로
* orderBy 로 흐르면 스키마 노출·비인덱스 정렬 DoS 표면이 됩니다.
* `name` 은 JSON 다국어 컬럼이라 정렬 대상에서 제외합니다.
* 권한은 라우트 permission 미들웨어 체인이 담당합니다 (authorize true 고정 규정).
*/
class IndexBoardRequest extends FormRequest
{
/**
* 요청 권한 — permission 미들웨어 체인이 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 전 입력 데이터 정규화
*
* 정렬 방향을 소문자로 정규화합니다 (asc/ASC 혼용 클라이언트 수용).
*/
protected function prepareForValidation(): void
{
if (is_string($this->input('sort_order'))) {
$this->merge(['sort_order' => strtolower($this->input('sort_order'))]);
}
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, array<int, mixed>> 검증 규칙
*/
public function rules(): array
{
return [
'page' => ['nullable', 'integer', 'min:1'],
// 게시판은 운영자 등록 설정성 테이블 — 전체 목록 셀렉트 소비처
// (게시판 설정 999 / 쇼핑몰 환경설정 문의 카드 200)가 이 상한에 묶인다.
// 100 으로 조이면 두 화면 진입이 422 로 깨진다 (BoardIndexSortTest 회귀 고정).
'per_page' => ['nullable', 'integer', 'min:1', 'max:999'],
'type' => ['nullable', 'string', 'max:50'],
'search' => ['nullable', 'string', 'max:100'],
'sort_by' => ['nullable', 'string', 'in:id,slug,type,is_active,posts_count,comments_count,created_at,updated_at'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
];
}
}
@@ -40,7 +40,7 @@ class IndexReportRequest extends FormRequest
'board_id' => ['nullable', 'integer'],
'reported_at_from' => ['nullable', 'string'],
'reported_at_to' => ['nullable', 'string'],
'sort_by' => ['nullable', 'string'],
// sort_by 는 받지 않는다 — 저장소가 last_reported_at 고정 정렬 (死규칙 정리)
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
'per_page' => ['nullable', 'integer'],
];
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use App\Models\Role;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
/**
@@ -174,6 +175,7 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.use_comment' => ['nullable', 'boolean'],
'basic_defaults.use_reply' => ['nullable', 'boolean'],
'basic_defaults.max_reply_depth' => ['nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"],
'basic_defaults.reply_delete_policy' => ['nullable', 'string', Rule::in(ReplyDeletePolicy::values())],
'basic_defaults.max_comment_depth' => ['nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"],
'basic_defaults.comment_order' => ['nullable', 'string', 'in:ASC,DESC'],
'basic_defaults.show_view_count' => ['nullable', 'boolean'],
@@ -39,7 +39,7 @@ class BlindCommentRequest extends FormRequest
public function messages(): array
{
return [
'reason.min' => __('sirsoft-board::validation.blind.reason.min'),
// reason 규칙에 min 이 없으므로 min 메시지는 매핑하지 않는다 (死매핑 정리 — lang 키 자체는 유지)
'reason.max' => __('sirsoft-board::validation.blind.reason.max'),
];
}
@@ -39,7 +39,7 @@ class BlindPostRequest extends FormRequest
public function messages(): array
{
return [
'reason.min' => __('sirsoft-board::validation.blind.reason.min'),
// reason 규칙에 min 이 없으므로 min 메시지는 매핑하지 않는다 (死매핑 정리 — lang 키 자체는 유지)
'reason.max' => __('sirsoft-board::validation.blind.reason.max'),
];
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 댓글 삭제 요청을 검증합니다.
*
* 비회원 댓글 삭제 시 본문 password 를 받는다 — 종전에는 컨트롤러가 전역
* `request()->input('password')` 로 읽어(저장소 유일 사례) 배열 주입이 그대로
* 비교 로직에 유입될 수 있었다. 소유권 판정 자체는 CommentService::canDelete 가
* 담당하므로 여기서는 형식만 닫는다 (형제 store/update/verifyPassword 와 대칭).
*/
class DestroyCommentRequest extends FormRequest
{
/**
* 요청 권한 — 소유권/권한 판정은 Service(canDelete)가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 검증 규칙
*/
public function rules(): array
{
return [
'password' => ['nullable', 'string', 'min:4', 'max:20'],
];
}
/**
* 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다.
*
* @return array<string, string> 필드별 표시명
*/
public function attributes(): array
{
return [
'password' => __('sirsoft-board::validation.attributes.comment.password'),
];
}
}
@@ -45,4 +45,16 @@ class RestorePostRequest extends FormRequest
'reason.max' => __('sirsoft-board::validation.restore.reason.max'),
];
}
/**
* 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다 (RestoreCommentRequest 와 대칭).
*
* @return array<string, string> 필드별 표시명
*/
public function attributes(): array
{
return [
'reason' => __('sirsoft-board::validation.attributes.restore.reason'),
];
}
}
@@ -10,6 +10,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange;
use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule;
@@ -54,6 +55,7 @@ class StoreBoardRequest extends FormRequest
'use_comment' => $settings['use_comment'] ?? true,
'use_reply' => $settings['use_reply'] ?? true,
'max_reply_depth' => $settings['max_reply_depth'] ?? 5,
'reply_delete_policy' => $settings['reply_delete_policy'] ?? ReplyDeletePolicy::Cascade->value,
'max_comment_depth' => $settings['max_comment_depth'] ?? 10,
'use_file_upload' => $settings['use_file_upload'] ?? false,
'comment_order' => $settings['comment_order'] ?? 'ASC',
@@ -207,6 +209,7 @@ class StoreBoardRequest extends FormRequest
// 답글/대댓글 깊이 제한
'max_reply_depth' => ['nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"],
'reply_delete_policy' => ['required', 'string', Rule::in(ReplyDeletePolicy::values())],
'max_comment_depth' => ['nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"],
// 알림 설정
@@ -10,6 +10,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange;
use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule;
@@ -207,6 +208,7 @@ class UpdateBoardRequest extends FormRequest
// 답글/대댓글 깊이 제한
'max_reply_depth' => ['sometimes', 'nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"],
'reply_delete_policy' => ['sometimes', 'nullable', 'string', Rule::in(ReplyDeletePolicy::values())],
'max_comment_depth' => ['sometimes', 'nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"],
// 알림 설정
@@ -0,0 +1,51 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
/**
* 게시글 삭제 요청을 검증합니다.
*
* 비회원 게시글 삭제 시 본문 password 또는 verification_token 을 받는다 —
* 종전에는 무검증으로 읽혀 배열 주입이 비교 로직에 유입될 수 있었다.
* 소유권 판정 자체는 컨트롤러(canModifyPost)가 담당하므로 여기서는 형식만 닫는다.
*/
class DestroyPostRequest extends FormRequest
{
/**
* 요청 권한 — 소유권/권한 판정은 컨트롤러(canModifyPost)가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, array<int, mixed>> 검증 규칙
*/
public function rules(): array
{
return [
'password' => ['nullable', 'string', 'min:4'],
'verification_token' => ['nullable', 'string', 'max:255'],
'force_delete' => ['nullable', 'boolean'],
];
}
/**
* 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다.
*
* @return array<string, string> 필드별 표시명
*/
public function attributes(): array
{
return [
'password' => __('sirsoft-board::validation.attributes.post.password'),
];
}
}
@@ -103,6 +103,9 @@ class UpdatePostRequest extends FormRequest
'attachment_ids.*' => ['integer', 'min:1'],
// temp_key는 게시판 존재 여부와 무관하게 항상 허용
'temp_key' => ['nullable', 'string', 'max:64'],
// 비회원 소유권 확인용 — 판정은 컨트롤러(canModifyPost), 여기서는 형식만 닫는다 (배열 주입 422)
'password' => ['nullable', 'string', 'min:4'],
'verification_token' => ['nullable', 'string', 'max:255'],
];
// 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공
@@ -56,6 +56,7 @@ class BoardResource extends BaseApiResource
'use_comment' => $this->use_comment,
'use_reply' => $this->use_reply,
'max_reply_depth' => $this->max_reply_depth,
'reply_delete_policy' => $this->reply_delete_policy,
'use_report' => $this->use_report,
'comment_order' => $this->comment_order,
'max_comment_depth' => $this->max_comment_depth,
@@ -192,6 +193,7 @@ class BoardResource extends BaseApiResource
'use_file_upload' => $this->use_file_upload,
'use_comment' => $this->use_comment,
'use_reply' => $this->use_reply,
'reply_delete_policy' => $this->reply_delete_policy,
'use_report' => $this->use_report,
'secret_mode' => $this->secret_mode,
'show_view_count' => $this->show_view_count,
@@ -224,6 +226,7 @@ class BoardResource extends BaseApiResource
'use_file_upload' => $this->use_file_upload,
'use_comment' => $this->use_comment,
'use_reply' => $this->use_reply,
'reply_delete_policy' => $this->reply_delete_policy,
'use_report' => $this->use_report,
'secret_mode' => $this->secret_mode,
'per_page' => $this->per_page,
@@ -85,6 +85,12 @@ class EcommerceInquiryHookListener implements HookListenerInterface
'priority' => 10,
'type' => 'filter',
],
// 이커머스 → 게시판: 살아있는 답변 수 조회 (Filter 훅)
'sirsoft-ecommerce.inquiry.count_replies' => [
'method' => 'countReplies',
'priority' => 10,
'type' => 'filter',
],
];
}
@@ -107,7 +113,8 @@ class EcommerceInquiryHookListener implements HookListenerInterface
* @param mixed $carry 이전 필터 결과 (초기값: null)
* @param string $slug 게시판 슬러그
* @param array $data 게시글 생성 데이터
* @return array|null 성공 시 ['post_id' => int, 'inquirable_type' => string], 실패 시 null
* @return array|null 성공 시 ['post_id' => int, 'inquirable_type' => string],
* 중복 답변 차단 시 ['duplicate' => true], 실패 시 null
*/
public function createAndReturn(mixed $carry, string $slug, array $data): ?array
{
@@ -124,6 +131,20 @@ class EcommerceInquiryHookListener implements HookListenerInterface
$data['ip_address'] = '0.0.0.0';
}
// 2차 방어(게시판 실데이터): 이미 살아있는 답변이 있는 부모에는 자식 Post 생성 거부.
// 1차 방어(피벗 is_answered)는 서비스가 담당하지만, API 동시 호출/직접 호출로
// 플래그가 어긋난 경우에도 게시판에 중복 답변이 쌓이지 않도록 여기서 최종 차단한다.
if (! empty($data['parent_id']) && $this->postRepository->findFirstReplyWithBoard((int) $data['parent_id']) !== null) {
Log::warning('EcommerceInquiryHookListener: 기존 답변이 있는 문의에 중복 답변 생성 시도 차단', [
'slug' => $slug,
'parent_id' => $data['parent_id'],
]);
// null(훅 무응답/실패)과 구분되는 중복 마커 — 호출 서비스가 이 마커로
// "이미 등록된 답변" 사유를 사용자에게 그대로 안내한다.
return ['duplicate' => true];
}
// parent_id 있으면 답변글 → 부모 Post 제목으로 Re: 원글제목 설정
if (! empty($data['parent_id']) && empty($data['title'])) {
$parentPost = $this->postRepository->findWithBoard($data['parent_id']);
@@ -342,8 +363,14 @@ class EcommerceInquiryHookListener implements HookListenerInterface
throw new ModelNotFoundException("Post {$postId} or its board could not be found.");
}
// 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴)
$this->postService->deletePost($post->board->slug, $postId, options: ['skip_notification' => true]);
// 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴).
// cascade_replies: 훅 경유 삭제는 게시판 답글 삭제 정책(block)과 무관하게 답변을
// 함께 정리한다 — 문의 답변은 시스템 생성이므로 정책에 막히면 안 되고,
// 기설치본의 다건 고아 답변도 cascade 스윕이 함께 정리한다.
$this->postService->deletePost($post->board->slug, $postId, options: [
'skip_notification' => true,
'cascade_replies' => true,
]);
} catch (ModelNotFoundException $e) {
Log::warning('EcommerceInquiryHookListener: Post 삭제 실패 - 게시글 또는 게시판 없음', [
'post_id' => $postId,
@@ -446,8 +473,12 @@ class EcommerceInquiryHookListener implements HookListenerInterface
throw new ModelNotFoundException("Reply Post {$reply->id}'s board could not be found.");
}
// 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴)
$this->postService->deletePost($reply->board->slug, $reply->id, options: ['skip_notification' => true]);
// 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴).
// cascade_replies: 답변 밑에 달린 자식 글까지 함께 정리 (정책 무관 — 시스템 생성 답변)
$this->postService->deletePost($reply->board->slug, $reply->id, options: [
'skip_notification' => true,
'cascade_replies' => true,
]);
} catch (\RuntimeException $e) {
throw $e;
} catch (ModelNotFoundException $e) {
@@ -473,6 +504,31 @@ class EcommerceInquiryHookListener implements HookListenerInterface
return $carry;
}
/**
* 부모 문의 Post 의 살아있는 답변 수 반환 (`sirsoft-ecommerce.inquiry.count_replies` 필터 훅)
*
* 이커머스 모듈이 답변 삭제 후 `is_answered` 재계산, 게시판 직권 삭제 시 답변완료
* 해제 판정에 사용합니다. SoftDeletes 전역 스코프가 삭제된 답변을 제외하므로
* "살아있는 답변" 만 집계됩니다.
*
* @param mixed $carry 이전 필터 결과 (초기값: 0)
* @param int $parentPostId 부모 문의 Post ID
* @return int 살아있는 답변 수 (조회 실패 시 이전 필터 결과 유지)
*/
public function countReplies(mixed $carry, int $parentPostId): int
{
try {
return $this->postRepository->countRepliesByParentId($parentPostId);
} catch (\Exception $e) {
Log::error('EcommerceInquiryHookListener: 답변 수 조회 실패', [
'parent_post_id' => $parentPostId,
'error' => $e->getMessage(),
]);
return (int) $carry;
}
}
// ─── 내부 유틸리티 ────────────────────────────────────────
/**
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Casts\AsAllowedExtensions;
use Modules\Sirsoft\Board\Database\Factories\BoardFactory;
use Modules\Sirsoft\Board\Enums\BoardOrderBy;
use Modules\Sirsoft\Board\Enums\OrderDirection;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Enums\SecretMode;
class Board extends Model
@@ -33,6 +34,7 @@ class Board extends Model
'use_comment' => ['label_key' => 'sirsoft-board::activity_log.fields.use_comment', 'type' => 'boolean'],
'use_reply' => ['label_key' => 'sirsoft-board::activity_log.fields.use_reply', 'type' => 'boolean'],
'max_reply_depth' => ['label_key' => 'sirsoft-board::activity_log.fields.max_reply_depth', 'type' => 'number'],
'reply_delete_policy' => ['label_key' => 'sirsoft-board::activity_log.fields.reply_delete_policy', 'type' => 'enum', 'enum' => ReplyDeletePolicy::class],
'use_report' => ['label_key' => 'sirsoft-board::activity_log.fields.use_report', 'type' => 'boolean'],
'use_file_upload' => ['label_key' => 'sirsoft-board::activity_log.fields.use_file_upload', 'type' => 'boolean'],
'max_file_size' => ['label_key' => 'sirsoft-board::activity_log.fields.max_file_size', 'type' => 'number'],
@@ -126,6 +128,7 @@ class Board extends Model
'use_comment',
'use_reply',
'max_reply_depth',
'reply_delete_policy',
'use_report',
'new_display_hours',
@@ -180,6 +183,7 @@ class Board extends Model
'use_comment' => 'boolean',
'use_reply' => 'boolean',
'max_reply_depth' => 'integer',
'reply_delete_policy' => ReplyDeletePolicy::class,
// Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다.
'new_display_hours' => 'integer',
'use_report' => 'boolean',
@@ -418,6 +418,60 @@ class AttachmentRepository implements AttachmentRepositoryInterface
]);
}
/**
* 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다.
*
* 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의
* 첨부 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다.
* 물리 파일은 보존하며 deleted_at 마킹만 수행합니다. (복원 가능성 유지)
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 삭제된 첨부 수
*/
public function softDeleteByPostIds(string $slug, array $postIds): int
{
if ($postIds === []) {
return 0;
}
$board = Board::where('slug', $slug)->first();
return Attachment::where('board_id', $board?->id)
->whereIn('post_id', $postIds)
->update([
'trigger_type' => TriggerType::Cascade->value,
'deleted_at' => now(),
]);
}
/**
* 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다.
*
* 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의
* 첨부 복원용입니다. 사용자가 직접 지운 첨부(trigger_type='user')는 유지됩니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 복원된 첨부 수
*/
public function restoreCascadedByPostIds(string $slug, array $postIds): int
{
if ($postIds === []) {
return 0;
}
$board = Board::where('slug', $slug)->first();
return Attachment::onlyTrashed()
->where('board_id', $board?->id)
->whereIn('post_id', $postIds)
->where('trigger_type', TriggerType::Cascade->value)
->update([
'deleted_at' => null,
]);
}
/**
* 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다.
*
@@ -700,6 +700,59 @@ class CommentRepository implements CommentRepositoryInterface
]);
}
/**
* 여러 게시글의 살아있는 댓글을 cascade 로 일괄 소프트 삭제합니다.
*
* 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의
* 댓글 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 삭제된 댓글 수
*/
public function softDeleteByPostIds(string $slug, array $postIds): int
{
if ($postIds === []) {
return 0;
}
$board = Board::where('slug', $slug)->first();
return Comment::where('board_id', $board?->id)
->whereIn('post_id', $postIds)
->update([
'trigger_type' => TriggerType::Cascade->value,
'deleted_at' => now(),
]);
}
/**
* 여러 게시글의 cascade 로 지워진 댓글만 일괄 복원합니다.
*
* 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의
* 댓글 복원용입니다. 사용자가 직접 지운 댓글(trigger_type='user')은 유지됩니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 복원된 댓글 수
*/
public function restoreCascadedByPostIds(string $slug, array $postIds): int
{
if ($postIds === []) {
return 0;
}
$board = Board::where('slug', $slug)->first();
return Comment::onlyTrashed()
->where('board_id', $board?->id)
->whereIn('post_id', $postIds)
->where('trigger_type', TriggerType::Cascade->value)
->update([
'deleted_at' => null,
]);
}
/**
* 사용자가 작성한 댓글 목록을 페이지네이션하여 조회합니다.
*
@@ -188,6 +188,24 @@ interface AttachmentRepositoryInterface
*/
public function restoreCascadedByPostId(string $slug, int $postId): int;
/**
* 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 삭제된 첨부 수
*/
public function softDeleteByPostIds(string $slug, array $postIds): int;
/**
* 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 복원된 첨부 수
*/
public function restoreCascadedByPostIds(string $slug, array $postIds): int;
/**
* 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다.
*
@@ -213,6 +213,24 @@ interface CommentRepositoryInterface
*/
public function restoreCascadedByPostId(string $slug, int $postId): int;
/**
* 여러 게시글의 살아있는 댓글을 cascade 로 일괄 소프트 삭제합니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 삭제된 댓글 수
*/
public function softDeleteByPostIds(string $slug, array $postIds): int;
/**
* 여러 게시글의 cascade 로 지워진 댓글만 일괄 복원합니다.
*
* @param string $slug 게시판 슬러그
* @param array<int> $postIds 게시글 ID 배열
* @return int 복원된 댓글 수
*/
public function restoreCascadedByPostIds(string $slug, array $postIds): int;
/**
* 사용자가 작성한 댓글 목록을 페이지네이션하여 조회합니다.
*
@@ -368,6 +368,59 @@ interface PostRepositoryInterface
*/
public function findFirstReplyWithBoard(int $parentPostId): ?Post;
/**
* 부모 게시글 ID로 살아있는 답변(자식) 게시글 수를 조회합니다.
*
* @param int $parentPostId 부모 게시글 ID
* @return int 살아있는 자식 게시글 수
*/
public function countRepliesByParentId(int $parentPostId): int;
/**
* 게시글에 살아있는 직계 답글이 있는지 확인합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @return bool 살아있는 직계 답글 존재 여부
*/
public function hasAliveReplies(string $slug, int $postId): bool;
/**
* 게시글의 전체 자손(답글 트리) ID 를 수집합니다 (withTrashed 순회 + 방문 가드).
*
* @param string $slug 게시판 슬러그
* @param int $postId 루트 게시글 ID
* @return array<int> 자손 게시글 ID 배열 (루트 미포함)
*/
public function collectDescendantIds(string $slug, int $postId): array;
/**
* 게시글의 살아있는 자손 답글 전체를 cascade 로 일괄 소프트 삭제합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 부모 게시글 ID
* @return array<int> 소프트 삭제된 자손 게시글 ID 배열
*/
public function softDeleteCascadeByParentId(string $slug, int $postId): array;
/**
* 게시글 복원 시, cascade 로 지워진 자손 답글만 top-down 으로 선택 복원합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 복원된 부모 게시글 ID
* @return array<int> 복원된 자손 게시글 ID 배열
*/
public function restoreCascadedByParentId(string $slug, int $postId): array;
/**
* 게시판의 전체 게시글 ID(withTrashed) 를 청크 단위로 순회하며 콜백에 전달합니다.
*
* @param int $boardId 게시판 ID
* @param int $size 청크 크기
* @param callable $callback 청크마다 호출될 콜백 (int[] $postIds)
*/
public function eachIdChunkByBoardId(int $boardId, int $size, callable $callback): void;
/**
* 게시글의 comments_count 컬럼을 활성 댓글 수로 재계산해 갱신합니다.
*
@@ -1719,6 +1719,206 @@ class PostRepository implements PostRepositoryInterface
->first();
}
/**
* 부모 게시글 ID로 살아있는 답변(자식) 게시글 수를 조회합니다.
*
* SoftDeletes 전역 스코프가 삭제된 답변을 자동 제외하므로,
* "삭제된 답변 후 재등록 허용" 판정의 근거로 사용됩니다.
*
* @param int $parentPostId 부모 게시글 ID
* @return int 살아있는 자식 게시글 수
*/
public function countRepliesByParentId(int $parentPostId): int
{
return Post::where('parent_id', $parentPostId)->count();
}
/**
* 게시글에 살아있는 직계 답글이 있는지 확인합니다.
*
* 답글 삭제 정책(block) 의 차단 판정 기준입니다. 살아있는 자손은 살아있는
* 부모 체인이 필요하므로 직계 검사만으로 판정이 완결됩니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
* @return bool 살아있는 직계 답글 존재 여부
*/
public function hasAliveReplies(string $slug, int $postId): bool
{
$board = Board::where('slug', $slug)->first();
return Post::where('board_id', $board?->id)
->where('parent_id', $postId)
->exists();
}
/**
* 게시글의 전체 자손(답글 트리) ID 를 수집합니다.
*
* 반복 BFS + 방문 ID 집합 가드로 순환 데이터에서도 유한 종료를 보장합니다(규정).
* 삭제된 중간 노드 밑의 자손도 도달해야 하므로 순회는 withTrashed 로 수행합니다
* (끊긴 체인 밑 과거 고아 스윕의 전제).
*
* @param string $slug 게시판 슬러그
* @param int $postId 루트 게시글 ID
* @return array<int> 자손 게시글 ID 배열 (루트 미포함)
*/
public function collectDescendantIds(string $slug, int $postId): array
{
$board = Board::where('slug', $slug)->first();
if ($board === null) {
return [];
}
$visited = [$postId => true];
$frontier = [$postId];
$descendants = [];
while ($frontier !== []) {
$children = Post::withTrashed()
->where('board_id', $board->id)
->whereIn('parent_id', $frontier)
->pluck('id')
->all();
$next = [];
foreach ($children as $childId) {
if (isset($visited[$childId])) {
continue;
}
$visited[$childId] = true;
$next[] = $childId;
$descendants[] = $childId;
}
$frontier = $next;
}
return $descendants;
}
/**
* 게시글의 살아있는 자손 답글 전체를 cascade 로 일괄 소프트 삭제합니다.
*
* 자손 중 살아있는 것만 단일 UPDATE 로 `status='deleted', trigger_type='cascade',
* deleted_at=now()` 마킹합니다 (status 포함: 관리자 삭제 탭 필터가 status 기준).
* 이미 trashed 인 자손(trigger 'user' 등)은 기본 스코프 밖이라 변조되지 않습니다.
* 작업 이력(action_log)은 부모에만 남깁니다 — 댓글 cascade 와 동일한 규약입니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 부모 게시글 ID
* @return array<int> 소프트 삭제된 자손 게시글 ID 배열
*/
public function softDeleteCascadeByParentId(string $slug, int $postId): array
{
$descendantIds = $this->collectDescendantIds($slug, $postId);
if ($descendantIds === []) {
return [];
}
// 살아있는 자손만 선별 (기본 스코프가 trashed 제외)
$aliveIds = Post::whereIn('id', $descendantIds)->pluck('id')->all();
if ($aliveIds === []) {
return [];
}
Post::whereIn('id', $aliveIds)->update([
'status' => PostStatus::Deleted->value,
'trigger_type' => TriggerType::Cascade->value,
'deleted_at' => now(),
]);
return $aliveIds;
}
/**
* 게시글 복원 시, cascade 로 지워진 자손 답글만 top-down 으로 선택 복원합니다.
*
* 레벨 순회 + 방문 가드: 각 레벨에서 `parent_id ∈ (복원됨 ∪ alive)` 인
* cascade-trashed 자손만 복원합니다. 사용자 직접 삭제('user')로 trashed 인
* 중간 노드는 복원되지 않고 그 서브트리도 그대로 유지됩니다(고아 재생성 금지).
*
* 트레이드오프(의도): 삭제 전 blinded 였던 자손도 복원 시 published 가 됩니다 —
* 부모 restorePost 의 기존 의미론(updateStatus published)과 동일합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 복원된 부모 게시글 ID
* @return array<int> 복원된 자손 게시글 ID 배열
*/
public function restoreCascadedByParentId(string $slug, int $postId): array
{
$board = Board::where('slug', $slug)->first();
if ($board === null) {
return [];
}
$visited = [$postId => true];
$frontier = [$postId];
$restored = [];
while ($frontier !== []) {
// 이미 살아있는 자식 — 더 깊은 레벨의 cascade 복원 통로로만 사용
$aliveChildren = Post::where('board_id', $board->id)
->whereIn('parent_id', $frontier)
->pluck('id')
->all();
// cascade 로 지워진 자식만 복원 대상
$toRestore = Post::onlyTrashed()
->where('board_id', $board->id)
->whereIn('parent_id', $frontier)
->where('trigger_type', TriggerType::Cascade->value)
->pluck('id')
->all();
if ($toRestore !== []) {
Post::onlyTrashed()->whereIn('id', $toRestore)->update([
'status' => PostStatus::Published->value,
'deleted_at' => null,
]);
$restored = array_merge($restored, $toRestore);
}
$next = [];
foreach (array_merge($aliveChildren, $toRestore) as $childId) {
if (isset($visited[$childId])) {
continue;
}
$visited[$childId] = true;
$next[] = $childId;
}
$frontier = $next;
}
return $restored;
}
/**
* 게시판의 전체 게시글 ID 를 청크 단위로 순회하며 콜백에 전달합니다.
*
* 게시판 삭제 벌크 훅(board.posts.before_force_delete)의 페이로드 공급용입니다.
* withTrashed 포함(삭제 대상은 trashed 도 물리 제거되므로), chunkById(키셋) 로
* OFFSET 밀림 없이 순회합니다.
*
* @param int $boardId 게시판 ID
* @param int $size 청크 크기
* @param callable $callback 청크마다 호출될 콜백 (int[] $postIds)
*/
public function eachIdChunkByBoardId(int $boardId, int $size, callable $callback): void
{
Post::withTrashed()
->where('board_id', $boardId)
->select('id')
->chunkById($size, function ($posts) use ($callback) {
$callback($posts->pluck('id')->all());
});
}
/**
* 비활성 게시판 ID 목록을 조회합니다.
*
@@ -13,7 +13,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\PermissionHelper;
use App\Models\Menu;
use App\Models\Role;
use App\Models\User;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Services\MenuService;
use App\Services\RoleService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -42,6 +42,26 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class BoardService
{
use ClearsTemplateCaches;
use ResolvesSortSpec;
/**
* 관리자 게시판 목록 정렬 허용 컬럼 화이트리스트
*
* IndexBoardRequest 의 sort_by in: 목록과 동일해야 한다 (화면 ⊆ 게이트 ⊆ 저장소).
* `name` 은 JSON 다국어 컬럼이라 제외한다.
*
* @var array<int, string>
*/
private const BOARD_SORT_COLUMNS = [
'id',
'slug',
'type',
'is_active',
'posts_count',
'comments_count',
'created_at',
'updated_at',
];
/**
* BoardService 생성자
@@ -106,13 +126,21 @@ class BoardService
});
}
// 정렬
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
// 정렬 — 요청 유래 값은 닫힌 집합으로만 해석 (미허용 컬럼은 기본값 fallback: 이중 방어)
[$sortSpec] = $this->resolveSortSpec(
$filters,
self::BOARD_SORT_COLUMNS,
'created_at',
);
$query->orderBy($sortSpec['column'], $sortSpec['direction']);
// 페이지네이션
$perPage = $filters['per_page'] ?? 20;
// 비고유 컬럼 동률 구간에서도 전순서 보장 (페이지 경계 중복/누락 방지 규정)
$query->orderBy('id', 'desc');
// 페이지네이션 (1~999 클램프 — 게이트 우회 내부 호출 대비 이중 방어)
// 상한 999 는 IndexBoardRequest 와 동일해야 한다: 게시판은 운영자 등록
// 설정성 테이블이라 전체 목록 셀렉트 소비처(999/200)가 이 값을 쓴다.
$perPage = min(max((int) ($filters['per_page'] ?? 20), 1), 999);
return $query->paginate($perPage);
}
@@ -532,6 +560,15 @@ class BoardService
// (소프트 삭제 시 게시판이 사라진 뒤 접근 불가한 고아 데이터로 잔존)
$this->attachmentRepository->forceDeleteByBoardId($board->id);
$this->commentRepository->forceDeleteByBoardId($board->id);
// 2-1. 훅: 삭제될 글 ID 목록을 연동 확장에 통지 (이커머스 문의 피벗 정리 등)
// withTrashed 전체를 1,000건 chunk 당 1회씩 다회 발화한다 — 리스너는 멱등 의무.
// 트랜잭션 내부 발화라 게시판 삭제와 원자적이다. per-post after_delete 훅을
// 재사용하지 않는 이유: 글마다 모델 로드 N회 + 소프트/하드 삭제 의미 불일치.
$this->postRepository->eachIdChunkByBoardId($board->id, 1000, function (array $postIds) use ($board) {
HookManager::doAction('sirsoft-board.board.posts.before_force_delete', $board, $postIds);
});
$this->postRepository->forceDeleteByBoardId($board->id);
// 3. 게시판 권한 삭제 (그누보드7 규정: detach 후 삭제)
@@ -742,7 +779,7 @@ class BoardService
}
// 그누보드7 규정: detach 후 삭제
$role->permissions()->detach();
$this->roleRepository->detachAllPermissions($role);
$role->users()->detach();
$this->roleRepository->delete($role);
}
@@ -19,6 +19,8 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
@@ -376,33 +378,57 @@ class PostService
* ① 게시글 상태 변경(deleted) + 소프트 삭제
* ② 살아있는 댓글을 cascade 로 소프트 삭제 (trigger_type='cascade')
* ③ 살아있는 첨부를 cascade 로 소프트 삭제 (trigger_type='cascade')
* ④ 살아있는 자손 답글 전체를 cascade 로 소프트 삭제 (+ 그 답글들의 댓글/첨부)
*
* 이미 사용자가 직접 삭제한 댓글/첨부(trigger_type='user' 등)는 영향을 받지 않으며,
* 게시글 복원 시 cascade 로 지워진 항목만 선택 복원됩니다.
* 위 3단계는 단일 트랜잭션으로 묶여 부분 실패 시 전체 롤백됩니다.
* 게시판 설정 `reply_delete_policy` 가 'block' 이면 살아있는 직계 답글 존재 시
* before_delete 훅 발화 전에 차단합니다(부수효과 0). 훅 경유 삭제는
* `options['cascade_replies']=true` 로 정책을 우회해 cascade 를 고정합니다
* (문의 답변은 시스템 생성이므로 block 정책에 막히면 안 됨).
*
* 이미 사용자가 직접 삭제한 답글/댓글/첨부(trigger_type='user' 등)는 영향을 받지
* 않으며, 게시글 복원 시 cascade 로 지워진 항목만 선택 복원됩니다.
* 위 단계는 단일 트랜잭션으로 묶여 부분 실패 시 전체 롤백됩니다.
* after_delete 훅은 부모 게시글에 대해서만 발화합니다 (자손별 발화 시 알림 폭주).
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param string|null $triggerType 트리거 유형 (admin, report 등)
* @param array $options 옵션 배열 (skip_notification: 알림 발송 SKIP)
* @param array $options 옵션 배열 (skip_notification: 알림 발송 SKIP, cascade_replies: 답글 삭제 정책 우회)
* @return Post 삭제 처리된 게시글
*
* @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우
* @throws PostHasRepliesException block 정책 게시판에서 살아있는 답글이 있는 경우
*/
public function deletePost(string $slug, int $id, ?string $triggerType = null, array $options = []): Post
{
// 게시판 존재성 검증
$this->validateBoardExists($slug);
// 게시판 존재성 검증 (정책 판정에 게시판 모델이 필요해 직접 조회)
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
throw new ModelNotFoundException(__('sirsoft-board::messages.errors.board_not_found'));
}
$post = $this->postRepository->findOrFail($slug, $id);
// 답글 삭제 정책 판정 — 훅 경유 삭제(cascade_replies 옵션)는 시스템 생성 답글을
// 함께 정리해야 하므로 게시판 정책과 무관하게 cascade 고정
$policy = ($options['cascade_replies'] ?? false)
? ReplyDeletePolicy::Cascade
: ($board->reply_delete_policy ?? ReplyDeletePolicy::Cascade);
// block 정책: 살아있는 직계 답글이 있으면 before_delete 훅 발화 전에 차단
// (차단 시 부수효과 0 — 훅 리스너의 외부 정리가 먼저 실행되면 되돌릴 수 없다)
if ($policy === ReplyDeletePolicy::Block && $this->postRepository->hasAliveReplies($slug, $id)) {
throw new PostHasRepliesException;
}
// 훅: before_delete
HookManager::doAction('sirsoft-board.post.before_delete', $post, $slug);
// 작업 이력 생성
$actionLog = $this->buildActionLog('delete', null);
// 게시글 + 하위 데이터(댓글/첨부) 연쇄 소프트 삭제를 단일 트랜잭션으로 처리
// 게시글 + 하위 데이터(답글/댓글/첨부) 연쇄 소프트 삭제를 단일 트랜잭션으로 처리
$deletedPost = DB::transaction(function () use ($slug, $id, $actionLog, $triggerType) {
// ① 상태 변경 (deleted로 변경하고 소프트 삭제)
$deletedPost = $this->postRepository->updateStatus($slug, $id, 'deleted', $actionLog, $triggerType);
@@ -414,10 +440,22 @@ class PostService
// ③ 첨부 cascade 소프트 삭제 (사용자가 이미 삭제한 항목은 미영향)
$this->attachmentRepository->softDeleteByPostId($slug, $id);
// ④ 살아있는 자손 답글 cascade 소프트 삭제 — block 통과 시에도 실행
// (끊긴 체인 밑 과거 고아 답글 스윕. 살아있는 직계가 없어도 고아 자손은 존재 가능)
$descendantIds = $this->postRepository->softDeleteCascadeByParentId($slug, $id);
// ⑤ 함께 지워진 자손 답글들의 댓글/첨부도 cascade 소프트 삭제
if ($descendantIds !== []) {
$this->commentRepository->softDeleteByPostIds($slug, $descendantIds);
$this->attachmentRepository->softDeleteByPostIds($slug, $descendantIds);
}
return $deletedPost;
});
// 훅: after_delete ($options 전달 — skip_notification 등 수신 리스너에서 활용)
// 자손 답글별로는 발화하지 않는다 — 자손별 발화 시 알림이 폭주하며,
// 이커머스 문의 피벗은 문의 원글에만 걸려 있어 부모 발화만으로 충분하다.
HookManager::doAction('sirsoft-board.post.after_delete', $deletedPost, $slug, $options);
// 캐시 무효화
@@ -508,6 +546,17 @@ class PostService
// ③ cascade 로 지워진 첨부만 선택 복원
$this->attachmentRepository->restoreCascadedByPostId($slug, $id);
// ④ cascade 로 지워진 자손 답글만 top-down 선택 복원 — 정책값과 무관하게 항상 실행
// (trigger_type='cascade' 로 마킹된 것만 복원되므로 안전. 사용자 직접 삭제 답글의
// 서브트리는 복원되지 않는다)
$restoredIds = $this->postRepository->restoreCascadedByParentId($slug, $id);
// ⑤ 되살아난 자손 답글들의 댓글/첨부도 cascade 분만 복원
if ($restoredIds !== []) {
$this->commentRepository->restoreCascadedByPostIds($slug, $restoredIds);
$this->attachmentRepository->restoreCascadedByPostIds($slug, $restoredIds);
}
return $restoredPost;
});
@@ -90,6 +90,7 @@ return [
'use_comment' => 'Use Comments',
'use_reply' => 'Use Replies',
'max_reply_depth' => 'Max Reply Depth',
'reply_delete_policy' => 'Reply Delete Policy',
'use_report' => 'Use Reports',
'use_file_upload' => 'Use File Upload',
'max_file_size' => 'Max File Size',
@@ -14,6 +14,12 @@ return [
'always' => 'Always Required',
],
// 답글 삭제 정책
'reply_delete_policy' => [
'block' => 'Block deletion (cannot delete posts with replies)',
'cascade' => 'Delete together (replies are deleted with the post)',
],
// Order Direction
'order_direction' => [
'asc' => 'Ascending',
@@ -263,6 +263,9 @@ return [
'notice_not_allowed' => 'Replies cannot be created on notice posts.',
],
'reply_not_allowed' => 'Reply feature is disabled for this board.',
'delete' => [
'has_replies' => 'Posts with replies cannot be deleted. Please delete the replies first.',
],
'status' => [
'in' => 'Invalid post status.',
],
@@ -303,6 +306,7 @@ return [
'basic_defaults.use_comment' => 'Use Comment',
'basic_defaults.use_reply' => 'Use Reply',
'basic_defaults.max_reply_depth' => 'Max Reply Depth',
'basic_defaults.reply_delete_policy' => 'Reply Delete Policy',
'basic_defaults.max_comment_depth' => 'Max Comment Depth',
'basic_defaults.comment_order' => 'Comment Order',
'basic_defaults.show_view_count' => 'Show View Count',
@@ -391,6 +395,7 @@ return [
'override_values.per_page' => 'Posts Per Page',
'override_values.per_page_mobile' => 'Posts Per Page (Mobile)',
'override_values.max_reply_depth' => 'Max Reply Depth',
'override_values.reply_delete_policy' => 'Reply Delete Policy',
'override_values.max_comment_depth' => 'Max Comment Depth',
'override_values.min_title_length' => 'Min Title Length',
'override_values.max_title_length' => 'Max Title Length',
@@ -90,6 +90,7 @@ return [
'use_comment' => '댓글 사용',
'use_reply' => '답글 사용',
'max_reply_depth' => '최대 답글 깊이',
'reply_delete_policy' => '답글 삭제 방식',
'use_report' => '신고 사용',
'use_file_upload' => '파일 업로드 사용',
'max_file_size' => '최대 파일 크기',
@@ -14,6 +14,12 @@ return [
'always' => '필수 사용',
],
// 답글 삭제 정책
'reply_delete_policy' => [
'block' => '삭제 차단 (답글이 있으면 원글 삭제 불가)',
'cascade' => '함께 삭제 (원글 삭제 시 답글도 함께 삭제)',
],
// 정렬 방향
'order_direction' => [
'asc' => '오름차순',
@@ -263,6 +263,9 @@ return [
'notice_not_allowed' => '공지사항에는 답글을 작성할 수 없습니다.',
],
'reply_not_allowed' => '이 게시판은 답글 기능이 비활성화되어 있습니다.',
'delete' => [
'has_replies' => '답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.',
],
'status' => [
'in' => '유효하지 않은 게시글 상태입니다.',
],
@@ -303,6 +306,7 @@ return [
'basic_defaults.use_comment' => '댓글 사용 여부',
'basic_defaults.use_reply' => '답글 사용 여부',
'basic_defaults.max_reply_depth' => '최대 답글 깊이',
'basic_defaults.reply_delete_policy' => '답글 삭제 방식',
'basic_defaults.max_comment_depth' => '최대 댓글 깊이',
'basic_defaults.comment_order' => '댓글 정렬',
'basic_defaults.show_view_count' => '조회수 표시',
@@ -391,6 +395,7 @@ return [
'override_values.per_page' => '페이지당 게시글 수',
'override_values.per_page_mobile' => '모바일 페이지당 게시글 수',
'override_values.max_reply_depth' => '최대 답글 깊이',
'override_values.reply_delete_policy' => '답글 삭제 방식',
'override_values.max_comment_depth' => '최대 댓글 깊이',
'override_values.min_title_length' => '최소 제목 길이',
'override_values.max_title_length' => '최대 제목 길이',
@@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Extension\Storage\ModuleStorageDriver;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@@ -61,6 +62,7 @@ class BoardDeletionTest extends ModuleTestCase
* 현행(soft delete) 코드에서는 withTrashed count 가 0 이 아니어서 fail (red).
*
* @scenario child_type=post
*
* @effects post_records_force_deleted,comment_records_force_deleted,attachment_records_force_deleted,board_itself_force_deleted
*/
public function test_deleting_board_force_deletes_child_records(): void
@@ -117,6 +119,7 @@ class BoardDeletionTest extends ModuleTestCase
* 남아 fail (red), slug 기반 삭제로 수정하면 green.
*
* @scenario child_type=attachment
*
* @effects attachment_physical_files_removed
*/
public function test_deleting_board_removes_attachment_physical_files(): void
@@ -146,6 +149,7 @@ class BoardDeletionTest extends ModuleTestCase
* 게시판이 사라져도 신고 레코드는 보존되고 board_id 만 NULL 이 된다.
*
* @scenario child_type=report
*
* @effects reports_preserved_with_null_board_id
*/
public function test_deleting_board_preserves_reports(): void
@@ -173,6 +177,7 @@ class BoardDeletionTest extends ModuleTestCase
* 댓글·첨부 등 하위 데이터가 영향받지 않음을 board 격리 관점에서 함께 보장)
*
* @scenario child_type=comment
*
* @effects other_board_files_not_affected
*/
public function test_deleting_board_does_not_affect_other_board_files(): void
@@ -197,6 +202,66 @@ class BoardDeletionTest extends ModuleTestCase
$this->boardService->deleteBoard($boardB->id);
}
/**
* 게시판 삭제 시 posts.before_force_delete 훅이 게시판 인스턴스와 함께
* 전체 글 ID(soft-deleted 포함)를 페이로드로 발화하는지 검증합니다.
*
* BoardService::deleteBoard 는 posts forceDelete 직전에 withTrashed 전체를
* 1,000건 chunk 당 1회 발화합니다 — 수집된 ID 합집합이 전체 글 ID 와 일치해야
* 연동 확장(이커머스 문의 피벗 정리 등)이 누락 없이 통지받습니다.
*/
public function test_fires_posts_before_force_delete_hook_with_all_ids(): void
{
$board = $this->createTestBoard('del-test-5');
// 일반 글 + soft-deleted 글 (둘 다 물리 삭제 대상이므로 훅 페이로드에 포함되어야 함)
$alivePost = Post::create([
'board_id' => $board->id,
'title' => '살아있는 게시글',
'content' => '본문',
'status' => PostStatus::Published,
'ip_address' => '127.0.0.1',
]);
$trashedPost = Post::create([
'board_id' => $board->id,
'title' => '소프트 삭제된 게시글',
'content' => '본문',
'status' => PostStatus::Deleted,
'ip_address' => '127.0.0.1',
]);
$trashedPost->delete();
$receivedIds = [];
$receivedBoardIds = [];
// 훅 스파이 — ModuleTestCase 가 setUp/tearDown 에서 HookManager 상태를
// 스냅샷/복원하므로 별도 정리는 불필요
HookManager::addAction(
'sirsoft-board.board.posts.before_force_delete',
function ($hookBoard, $postIds) use (&$receivedIds, &$receivedBoardIds) {
$receivedIds = array_merge($receivedIds, $postIds);
$receivedBoardIds[] = $hookBoard->id;
}
);
$this->boardService->deleteBoard($board->id);
// 수집된 ID 합집합 = 전체 글 ID (중복 발화 가능성 대비 unique 후 비교)
$union = array_values(array_unique($receivedIds));
sort($union);
$expected = [$alivePost->id, $trashedPost->id];
sort($expected);
$this->assertSame($expected, $union, '훅 페이로드 ID 합집합이 soft-deleted 포함 전체 글 ID 와 일치해야 합니다.');
// board 인자 전달 검증
$this->assertNotEmpty($receivedBoardIds, '훅이 최소 1회 발화되어야 합니다.');
foreach (array_unique($receivedBoardIds) as $receivedBoardId) {
$this->assertSame($board->id, $receivedBoardId, '훅의 첫 인자는 삭제 대상 게시판이어야 합니다.');
}
}
/**
* 테스트용 게시판을 생성합니다.
*
@@ -0,0 +1,138 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 관리자 게시판 목록 정렬/페이지 크기 게이트 테스트
*
* 검증 목적:
* - IndexBoardRequest: sort_by 화이트리스트(in:) 밖 컬럼 → 422 (스키마 노출·비인덱스 정렬 DoS 차단)
* - IndexBoardRequest: per_page 1~999 범위 밖 → 422 (상한은 전체 목록 셀렉트 소비처 수용치)
* - 화이트리스트 컬럼 정렬은 정상 동작 (sort_by=slug&sort_order=asc)
* - BoardService::getBoards 직접 호출(게이트 우회 내부 호출)도 미허용 컬럼을
* 기본 정렬로 fallback — 예외 없이 동작 (ResolvesSortSpec 이중 방어)
*
* @group board
* @group admin
*/
class BoardIndexSortTest extends ModuleTestCase
{
protected User $adminUser;
private const INDEX_URL = '/api/modules/sirsoft-board/admin/boards';
/**
* 테스트 환경 설정
*/
protected function setUp(): void
{
parent::setUp();
BoardType::firstOrCreate(
['slug' => 'basic'],
['name' => ['ko' => '기본', 'en' => 'Basic'], 'is_active' => true, 'is_default' => true]
);
$this->adminUser = $this->createAdminUser([
'sirsoft-board.boards.read',
]);
}
/**
* 화이트리스트 밖 정렬 컬럼(sort_by=settings) → 422.
*/
public function test_rejects_unknown_sort_column(): void
{
$this->actingAs($this->adminUser)
->getJson(self::INDEX_URL.'?sort_by=settings')
->assertStatus(422);
}
/**
* per_page 상한(999) 초과(per_page=1000) → 422.
*/
public function test_rejects_out_of_range_per_page(): void
{
$this->actingAs($this->adminUser)
->getJson(self::INDEX_URL.'?per_page=1000')
->assertStatus(422);
}
/**
* 전체 목록 셀렉트 소비처의 per_page 를 게이트가 수용한다 (회귀).
*
* 게시판은 운영자 등록 설정성 테이블이라 레이아웃이 사실상 전량 조회를 쓴다:
* - 게시판 설정 화면(admin_board_settings.json): per_page=999
* - 쇼핑몰 환경설정 문의 카드(admin-ecommerce-inquiry-settings.json): per_page=200
* 게이트를 100 으로 조였을 때 두 화면 진입이 422 토스트로 깨졌다 (#573 회귀).
*/
public function test_accepts_full_list_consumer_per_page(): void
{
foreach ([200, 999] as $perPage) {
$this->actingAs($this->adminUser)
->getJson(self::INDEX_URL.'?per_page='.$perPage)
->assertStatus(200);
}
}
/**
* 화이트리스트 컬럼 정렬(sort_by=slug&sort_order=asc)은 200 + 순서 보장.
*/
public function test_sorts_by_whitelisted_column(): void
{
$prefix = 'sort-'.uniqid();
// 생성 순서를 정렬 기대 순서와 다르게 섞는다 (기본 정렬 fallback 오탐 방지)
foreach (['c', 'a', 'b'] as $suffix) {
Board::create([
'name' => ['ko' => "정렬 테스트 {$suffix}"],
'slug' => "{$prefix}-{$suffix}",
'type' => 'basic',
'is_active' => true,
]);
}
$rows = $this->actingAs($this->adminUser)
->getJson(self::INDEX_URL."?search={$prefix}&sort_by=slug&sort_order=asc")
->assertStatus(200)
->json('data.data');
$slugs = array_column($rows, 'slug');
$this->assertSame(
["{$prefix}-a", "{$prefix}-b", "{$prefix}-c"],
$slugs,
'slug 오름차순 정렬이 적용되어야 합니다.'
);
}
/**
* BoardService::getBoards 직접 호출(게이트 우회 내부 호출)에 미허용 정렬 컬럼을
* 넘겨도 예외 없이 기본 정렬로 fallback 한다 (ResolvesSortSpec 이중 방어).
*/
public function test_service_ignores_unlisted_sort_column(): void
{
$this->actingAs($this->adminUser);
$result = app(BoardService::class)->getBoards([
'sort_by' => 'evil',
'sort_order' => 'asc',
]);
$this->assertInstanceOf(
LengthAwarePaginator::class,
$result,
'미허용 정렬 컬럼은 예외 없이 기본 정렬로 fallback 되어야 합니다.'
);
}
}
@@ -0,0 +1,430 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 답글 삭제 정책(reply_delete_policy) + 답글 트리 연쇄 삭제/복원 테스트
*
* 검증 목적:
* - cascade 정책(기본): 부모 삭제 시 자손 답글 전체 + 그 댓글/첨부가 cascade soft delete
* - 복원 시 cascade 로 지워진 자손 + 그 댓글/첨부만 top-down 선택 복원
* - 사용자 직접 삭제(trigger 'user') 서브트리는 부모 복원 후에도 trashed 유지
* - block 정책: 살아있는 직계 답글 존재 시 관리자/사용자 삭제 경로 모두 422 차단
* - block 정책이라도 답글이 전부 trashed 면 삭제 허용
* - 훅 경유 삭제(options cascade_replies=true)는 block 정책 우회 + cascade 고정
* - 이미 trashed 인 자손은 cascade 가 변조하지 않음 (trigger_type/deleted_at 불변)
* - 신고 조치 삭제(trigger 'report')에도 block 정책 동일 적용 (cascade_replies 옵션만 우회)
* - 끊긴 체인 밑 고아 답글은 block 통과 삭제에서도 cascade 스윕으로 함께 정리
*
* @group board
* @group admin
*/
class ReplyDeletePolicyTest extends BoardTestCase
{
private PostService $postService;
private User $adminWithManage;
protected function getTestBoardSlug(): string
{
return 'reply-delete-policy';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '답글 삭제 정책 테스트 게시판', 'en' => 'Reply Delete Policy Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
// 이전 테스트가 block 으로 바꾼 값이 잔류하지 않도록 매 테스트 기본값으로 리셋
'reply_delete_policy' => 'cascade',
];
}
protected function setUp(): void
{
parent::setUp();
$this->postService = app(PostService::class);
$slug = $this->board->slug;
$this->adminWithManage = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.manage",
]);
// 사용자 삭제 경로(회원 본인 글) 검증용
$this->grantUserRolePermissions(['posts.read', 'posts.write']);
}
// ==========================================
// Helper
// ==========================================
/**
* 관리자 게시글 API URL 을 반환합니다.
*
* @param string $suffix URL 접미사
* @return string 관리자 게시글 API URL
*/
private function adminUrl(string $suffix): string
{
return "/api/modules/sirsoft-board/admin/board/{$this->board->slug}/posts{$suffix}";
}
/**
* 사용자 게시글 API URL 을 반환합니다.
*
* @param string $suffix URL 접미사
* @return string 사용자 게시글 API URL
*/
private function userUrl(string $suffix): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts{$suffix}";
}
/**
* 답글(자식 게시글)을 직접 DB 에 생성합니다.
*
* @param int $parentId 부모 게시글 ID
* @param int $depth 답글 깊이
* @param array $attributes 덮어쓸 속성
* @return int 생성된 답글 ID
*/
private function createReply(int $parentId, int $depth = 1, array $attributes = []): int
{
return $this->createTestPost(array_merge([
'title' => "답글 (depth {$depth})",
'parent_id' => $parentId,
'depth' => $depth,
], $attributes));
}
/**
* 첨부파일을 직접 DB 에 생성합니다 (PostDeleteCascadeTest 와 동일 패턴).
*
* @param int $postId 게시글 ID
* @param array $attributes 덮어쓸 속성
* @return int 생성된 첨부 ID
*/
private function createTestAttachment(int $postId, array $attributes = []): int
{
static $seq = 0;
$seq++;
$defaults = [
'board_id' => $this->board->id,
'post_id' => $postId,
'hash' => 'r'.str_pad((string) $seq, 11, '0', STR_PAD_LEFT),
'original_filename' => 'test.png',
'stored_filename' => 'stored-'.$seq.'.png',
'disk' => 'local',
'path' => 'attachments/reply-'.$seq.'.png',
'mime_type' => 'image/png',
'size' => 1024,
'collection' => 'default',
'order' => 0,
'created_at' => now(),
'updated_at' => now(),
];
return DB::table('board_attachments')->insertGetId(array_merge($defaults, $attributes));
}
/**
* 게시글 행을 조회합니다.
*
* @param int $postId 게시글 ID
* @return object|null 게시글 행
*/
private function postRow(int $postId): ?object
{
return DB::table('board_posts')->where('id', $postId)->first();
}
// ==========================================
// cascade 정책 (기본)
// ==========================================
/**
* cascade 정책: 부모 삭제 시 깊이 3 답글 트리 전체 + 자손별 댓글/첨부가
* cascade soft delete + trigger_type='cascade' + status='deleted' 로 마킹된다.
*/
public function test_cascade_policy_soft_deletes_all_descendant_replies(): void
{
$parentId = $this->createTestPost(['status' => 'published']);
$reply1 = $this->createReply($parentId, 1);
$reply2 = $this->createReply($reply1, 2);
$reply3 = $this->createReply($reply2, 3);
$commentIds = [];
$attachmentIds = [];
foreach ([$reply1, $reply2, $reply3] as $replyId) {
$commentIds[$replyId] = $this->createTestComment($replyId);
$attachmentIds[$replyId] = $this->createTestAttachment($replyId);
}
$this->postService->deletePost($this->board->slug, $parentId, 'admin');
foreach ([$reply1, $reply2, $reply3] as $replyId) {
$row = $this->postRow($replyId);
$this->assertNotNull($row->deleted_at, "자손 답글 {$replyId} 이 soft delete 되어야 합니다.");
$this->assertSame('cascade', $row->trigger_type, "자손 답글 {$replyId} 이 cascade 로 마킹되어야 합니다.");
$this->assertSame('deleted', $row->status, "자손 답글 {$replyId} 의 status 가 deleted 여야 합니다.");
$comment = DB::table('board_comments')->where('id', $commentIds[$replyId])->first();
$this->assertNotNull($comment->deleted_at, "자손 답글 {$replyId} 의 댓글이 soft delete 되어야 합니다.");
$this->assertSame('cascade', $comment->trigger_type);
$attachment = DB::table('board_attachments')->where('id', $attachmentIds[$replyId])->first();
$this->assertNotNull($attachment->deleted_at, "자손 답글 {$replyId} 의 첨부가 soft delete 되어야 합니다.");
$this->assertSame('cascade', $attachment->trigger_type);
}
}
/**
* 복원 시 cascade 로 지워졌던 자손 답글 + 그 댓글/첨부가 함께 복원된다.
*/
public function test_cascade_restore_restores_descendants_and_their_children(): void
{
$parentId = $this->createTestPost(['status' => 'published']);
$reply1 = $this->createReply($parentId, 1);
$reply2 = $this->createReply($reply1, 2);
$commentId = $this->createTestComment($reply1);
$attachmentId = $this->createTestAttachment($reply2);
$this->postService->deletePost($this->board->slug, $parentId, 'admin');
$this->postService->restorePost($this->board->slug, $parentId, '복원', 'admin');
foreach ([$reply1, $reply2] as $replyId) {
$row = $this->postRow($replyId);
$this->assertNull($row->deleted_at, "cascade 자손 답글 {$replyId} 은 복원되어야 합니다.");
$this->assertSame('published', $row->status, "복원된 자손 답글 {$replyId} 의 status 가 published 여야 합니다.");
}
$comment = DB::table('board_comments')->where('id', $commentId)->first();
$this->assertNull($comment->deleted_at, 'cascade 자손 답글의 댓글도 복원되어야 합니다.');
$attachment = DB::table('board_attachments')->where('id', $attachmentId)->first();
$this->assertNull($attachment->deleted_at, 'cascade 자손 답글의 첨부도 복원되어야 합니다.');
}
/**
* 사용자 직접 삭제(trigger 'user') 로 먼저 지워진 중간 자손의 서브트리는
* 부모 삭제 → 복원 후에도 trashed 로 유지된다 (고아 재생성 금지).
*/
public function test_user_predeleted_reply_subtree_stays_trashed_after_restore(): void
{
$parentId = $this->createTestPost(['status' => 'published']);
$reply1 = $this->createReply($parentId, 1);
$reply2 = $this->createReply($reply1, 2);
// 중간 자손(reply1)을 사용자 삭제 경로로 먼저 삭제 → reply1 trigger 'user',
// 그 자식 reply2 는 reply1 삭제의 cascade 로 trashed
$this->postService->deletePost($this->board->slug, $reply1, 'user');
$this->assertSame('user', $this->postRow($reply1)->trigger_type, '테스트 전제: reply1 은 user 삭제여야 합니다.');
$this->assertNotNull($this->postRow($reply2)->deleted_at, '테스트 전제: reply2 는 reply1 삭제로 trashed 여야 합니다.');
// 부모 삭제 → 복원
$this->postService->deletePost($this->board->slug, $parentId, 'admin');
$this->postService->restorePost($this->board->slug, $parentId, '복원', 'admin');
$this->assertNull($this->postRow($parentId)->deleted_at, '부모 글은 복원되어야 합니다.');
$reply1Row = $this->postRow($reply1);
$this->assertNotNull($reply1Row->deleted_at, '사용자가 직접 지운 답글은 복원되면 안 됩니다.');
$this->assertSame('user', $reply1Row->trigger_type, '사용자 삭제 trigger_type 은 유지되어야 합니다.');
$this->assertNotNull(
$this->postRow($reply2)->deleted_at,
'사용자 삭제 답글의 서브트리도 trashed 로 유지되어야 합니다 (고아 재생성 금지).'
);
}
/**
* cascade 는 이미 trashed 인 자손(trigger 'user')을 변조하지 않는다
* (trigger_type 유지 + deleted_at 불변).
*/
public function test_cascade_skips_already_trashed_descendants(): void
{
$parentId = $this->createTestPost(['status' => 'published']);
$this->createReply($parentId, 1); // 살아있는 답글 — cascade 가 실제로 수행되는 전제
$preDeletedAt = now()->subHour();
$preDeletedReplyId = $this->createReply($parentId, 1, [
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => $preDeletedAt,
]);
$before = $this->postRow($preDeletedReplyId);
$this->postService->deletePost($this->board->slug, $parentId, 'admin');
$after = $this->postRow($preDeletedReplyId);
$this->assertSame('user', $after->trigger_type, '이미 trashed 인 자손의 trigger_type 은 유지되어야 합니다.');
$this->assertSame($before->deleted_at, $after->deleted_at, '이미 trashed 인 자손의 deleted_at 은 불변이어야 합니다.');
}
// ==========================================
// block 정책
// ==========================================
/**
* block 정책: 살아있는 답글이 있는 글의 관리자 삭제 → 422 + 정책 메시지, 부수효과 0.
*/
public function test_block_policy_returns_422_with_message_when_alive_reply_exists(): void
{
$this->updateBoardSettings(['reply_delete_policy' => 'block']);
$parentId = $this->createTestPost(['status' => 'published']);
$replyId = $this->createReply($parentId, 1);
$response = $this->actingAs($this->adminWithManage)
->deleteJson($this->adminUrl("/{$parentId}"));
$response->assertStatus(422);
$this->assertSame(
__('sirsoft-board::validation.post.delete.has_replies'),
$response->json('message'),
'답글 삭제 정책 차단 메시지가 응답되어야 합니다.'
);
// 차단 시 부수효과 0: 부모/답글 모두 미삭제
$this->assertNull($this->postRow($parentId)->deleted_at, '차단된 부모 글은 삭제되면 안 됩니다.');
$this->assertNull($this->postRow($replyId)->deleted_at, '차단 시 답글도 영향받지 않아야 합니다.');
}
/**
* block 정책: 답글이 전부 trashed 면 삭제가 허용된다.
*/
public function test_block_policy_allows_delete_when_replies_all_trashed(): void
{
$this->updateBoardSettings(['reply_delete_policy' => 'block']);
$parentId = $this->createTestPost(['status' => 'published']);
$this->createReply($parentId, 1, [
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => now()->subHour(),
]);
$this->actingAs($this->adminWithManage)
->deleteJson($this->adminUrl("/{$parentId}"))
->assertStatus(200);
$this->assertNotNull($this->postRow($parentId)->deleted_at, '답글이 전부 trashed 면 부모 글 삭제가 허용되어야 합니다.');
}
/**
* block 정책은 사용자 삭제 경로(회원 본인 글)에도 동일하게 적용된다 → 422.
*/
public function test_user_delete_path_applies_policy(): void
{
$this->updateBoardSettings(['reply_delete_policy' => 'block']);
$owner = $this->createUser();
$parentId = $this->createTestPost([
'status' => 'published',
'user_id' => $owner->id,
'author_name' => $owner->name,
]);
$this->createReply($parentId, 1);
$response = $this->actingAs($owner)
->deleteJson($this->userUrl("/{$parentId}"));
$response->assertStatus(422);
$this->assertSame(
__('sirsoft-board::validation.post.delete.has_replies'),
$response->json('message')
);
$this->assertNull($this->postRow($parentId)->deleted_at, '차단된 글은 삭제되면 안 됩니다.');
}
/**
* 신고 조치 삭제(trigger 'report')에도 block 정책이 동일하게 적용된다.
* 정책 우회는 오직 cascade_replies 옵션(훅 경유)뿐 — trigger 문자열은 우회 수단이 아니다.
*/
public function test_report_trigger_delete_applies_block_policy(): void
{
$this->updateBoardSettings(['reply_delete_policy' => 'block']);
$parentId = $this->createTestPost(['status' => 'published']);
$replyId = $this->createReply($parentId, 1);
try {
$this->postService->deletePost($this->board->slug, $parentId, 'report');
$this->fail('block 정책에서는 신고 조치 삭제도 PostHasRepliesException 으로 차단되어야 합니다.');
} catch (PostHasRepliesException $e) {
// 기대 경로: ReportService::deleteContent 의 기존 generic catch 가 이 예외를
// 조치 실패 사유로 로그 변환한다 (계획서 WP2 §차단 예외 — 정책 적용 유지).
}
$this->assertNull($this->postRow($parentId)->deleted_at, '차단된 부모 글은 삭제되면 안 됩니다.');
$this->assertNull($this->postRow($replyId)->deleted_at, '차단 시 답글도 영향받지 않아야 합니다.');
}
/**
* 끊긴 체인 밑 고아 답글: 직계 답글이 전부 trashed 라 block 정책이 삭제를 허용하는
* 경우에도 cascade 스윕이 실행되어, trashed 자손 밑에 살아남아 있던 고아 답글을
* 함께 정리한다 (계획서 WP2 §PostService — "block 통과 시에도 실행").
*/
public function test_orphan_reply_under_trashed_chain_is_swept_on_delete(): void
{
$this->updateBoardSettings(['reply_delete_policy' => 'block']);
$parentId = $this->createTestPost(['status' => 'published']);
$trashedChildId = $this->createReply($parentId, 1, [
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => now()->subHour(),
]);
$orphanGrandchildId = $this->createReply($trashedChildId, 2); // 끊긴 체인 밑에 살아있는 고아
// 직계 답글이 전부 trashed → block 정책이 삭제를 허용
$this->actingAs($this->adminWithManage)
->deleteJson($this->adminUrl("/{$parentId}"))
->assertStatus(200);
$orphanRow = $this->postRow($orphanGrandchildId);
$this->assertNotNull($orphanRow->deleted_at, '끊긴 체인 밑 고아 답글도 cascade 스윕으로 정리되어야 합니다.');
$this->assertSame('cascade', $orphanRow->trigger_type, '고아 답글은 cascade 로 마킹되어야 합니다.');
$this->assertSame('deleted', $orphanRow->status);
// 사용자 선삭제 중간 노드는 불변 (trigger_type/deleted_at 유지)
$this->assertSame('user', $this->postRow($trashedChildId)->trigger_type, '기존 trashed 중간 노드는 변조되면 안 됩니다.');
}
/**
* 훅 경유 삭제(options cascade_replies=true)는 block 정책을 우회하고
* 자손 답글까지 cascade 로 함께 정리한다 (시스템 생성 답변 정리 보장).
*/
public function test_ecommerce_hook_delete_bypasses_block_policy(): void
{
$this->updateBoardSettings(['reply_delete_policy' => 'block']);
$parentId = $this->createTestPost(['status' => 'published']);
$replyId = $this->createReply($parentId, 1);
$this->postService->deletePost($this->board->slug, $parentId, options: ['cascade_replies' => true]);
$this->assertNotNull($this->postRow($parentId)->deleted_at, 'cascade_replies 옵션은 block 정책을 우회해야 합니다.');
$replyRow = $this->postRow($replyId);
$this->assertNotNull($replyRow->deleted_at, '자손 답글도 함께 cascade 삭제되어야 합니다.');
$this->assertSame('cascade', $replyRow->trigger_type);
}
}
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature;
require_once __DIR__.'/../ModuleTestCase.php';
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
@@ -32,6 +33,16 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
*
* UpdatePostRequest:
* - title 최소 길이 미만 → 422
* - password 배열 주입 → 422
*
* DestroyCommentRequest:
* - password 배열 주입 → 422
* - password 최소 길이(4) 미만 → 422
* - 회원 본인 댓글 password 없이 삭제 → 200
*
* DestroyPostRequest:
* - verification_token 배열 주입 → 422
* - 비회원 글 올바른 password 로 삭제 → 200
*
* @group board
* @group feature
@@ -300,4 +311,111 @@ class FormRequestValidationTest extends BoardTestCase
['title' => '가'] // min_title_length=2 미만
)->assertStatus(422);
}
/**
* 게시글 수정에 password 배열 주입 → 422 (string 규칙 차단)
*/
public function test_post_update_rejects_array_password(): void
{
$user = $this->createUser();
$postId = $this->createTestPost(['user_id' => $user->id]);
$this->actingAs($user)->putJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}",
[
'title' => '정상 제목',
'password' => ['x'], // 배열 주입
]
)->assertStatus(422);
}
// ==========================================
// DestroyCommentRequest
// ==========================================
/**
* 댓글 삭제에 password 배열 주입 → 422 (string 규칙 차단)
*/
public function test_comment_destroy_rejects_array_password(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'password' => Hash::make('pass1234'),
]);
$this->deleteJson(
$this->commentUrl($postId)."/{$commentId}",
['password' => ['x']] // 배열 주입
)->assertStatus(422);
}
/**
* 댓글 삭제에 최소 길이(4) 미만 password → 422
*/
public function test_comment_destroy_rejects_too_short_password(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'password' => Hash::make('pass1234'),
]);
$this->deleteJson(
$this->commentUrl($postId)."/{$commentId}",
['password' => '123'] // min:4 미만
)->assertStatus(422);
}
/**
* 회원 본인 댓글은 password 없이 삭제 가능 → 200
* (password 는 nullable — 비회원 소유권 확인용으로만 요구)
*/
public function test_member_comment_destroy_without_password_succeeds(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $user->id,
'author_name' => $user->name,
]);
$this->actingAs($user)
->deleteJson($this->commentUrl($postId)."/{$commentId}")
->assertStatus(200);
}
// ==========================================
// DestroyPostRequest
// ==========================================
/**
* 게시글 삭제에 verification_token 배열 주입 → 422 (string 규칙 차단)
*/
public function test_post_destroy_rejects_array_verification_token(): void
{
$postId = $this->createTestPost([
'user_id' => null,
'password' => Hash::make('pass1234'),
]);
$this->deleteJson(
$this->postUrl()."/{$postId}",
['verification_token' => ['x']] // 배열 주입
)->assertStatus(422);
}
/**
* 비회원 글은 올바른 password 로 삭제 가능 → 200
*/
public function test_guest_post_destroy_with_valid_password_succeeds(): void
{
$postId = $this->createTestPost([
'user_id' => null,
'password' => Hash::make('pass1234'),
]);
$this->deleteJson(
$this->postUrl()."/{$postId}",
['password' => 'pass1234']
)->assertStatus(200);
}
}
@@ -19,6 +19,8 @@ import { issueToken, authenticatePage } from '../../../../../../tests/Playwright
type BoardAuthFixtures = {
/** 대시보드 진입 권한 + 게시판 조회/신고 조회 권한 토큰 */
dashboardToken: string;
/** 답글 삭제 정책 E2E 용 — 게시판 CRUD + 환경설정 + 전용 게시판(e2e-reply-policy) 글 관리 토큰 */
replyPolicyToken: string;
/** 환경설정 조회/수정 권한 토큰 */
settingsToken: string;
/** 권한 없는 일반 사용자 토큰 (영역 부재 검증용) */
@@ -47,6 +49,24 @@ export const test = base.extend<BoardAuthFixtures>({
),
);
},
replyPolicyToken: async ({}, use) => {
// 게시판 스코프 권한은 slug 가 식별자에 박히므로 전용 게시판 slug 로 발급.
// core.profile.read: 게시판 생성 필수값(board_manager_ids)에 넣을 본인 uuid 조회용.
await use(
issueToken(
'core.profile.read',
'sirsoft-board.boards.read',
'sirsoft-board.boards.create',
'sirsoft-board.boards.update',
'sirsoft-board.boards.delete',
'sirsoft-board.settings.read',
'sirsoft-board.settings.update',
'sirsoft-board.e2e-reply-policy.admin.posts.read',
'sirsoft-board.e2e-reply-policy.admin.posts.write',
'sirsoft-board.e2e-reply-policy.admin.manage',
),
);
},
noPermissionToken: async ({}, use) => {
await use(issueToken());
},
@@ -0,0 +1,182 @@
/**
* 게시판 답글 삭제 정책 (block/cascade) — 설정 노출·저장 왕복 + block 차단 (#573, 공개 #106/#107 후속).
*
* 검증 축:
* ① 게시판 환경설정 "답변글" 탭에 정책 Select 렌더 + 기본값 cascade
* ② 게시판 편집 폼에서 정책을 block 으로 저장 → 재조회 왕복
* ③ block 게시판에서 답글 달린 글 삭제 → 422 + has_replies 메시지 (DB 무변화)
*
* 단위(PHPUnit ReplyDeletePolicyTest)는 서비스/저장소 cascade 의미론을 담당하고,
* 이 spec 은 화면 노출과 API 계약(저장 왕복·422)의 브라우저 수준 확인을 담당한다.
*
* 전용 게시판(e2e-reply-policy)을 spec 안에서 생성/삭제하므로 시드 의존이 없다.
*
* @scenario post-reply-delete-policy
* @axes policy=block, policy=cascade
* @effects settings_and_form_expose_policy, block_returns_422_with_message
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
const SLUG = 'e2e-reply-policy';
const API = '/api/modules/sirsoft-board';
/** 브라우저 컨텍스트에서 Bearer 토큰으로 API 를 호출한다 (실 브라우저 실측 규약). */
async function api(
page: import('@playwright/test').Page,
bearer: string,
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; body: any }> {
return page.evaluate(
async ({ bearer, method, path, body }) => {
const response = await fetch(path, {
method,
headers: {
Authorization: `Bearer ${bearer}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
let parsed: unknown = null;
try {
parsed = await response.json();
} catch {
/* 비 JSON 응답 허용 */
}
return { status: response.status, body: parsed as any };
},
{ bearer, method, path, body },
);
}
async function deleteBoardIfExists(page: import('@playwright/test').Page, bearer: string): Promise<void> {
await api(page, bearer, 'DELETE', `${API}/admin/boards/${SLUG}`);
}
/** 전용 게시판을 생성한다 (StoreBoardRequest 필수값 포함). */
async function createBoard(
page: import('@playwright/test').Page,
bearer: string,
extra: Record<string, unknown> = {},
): Promise<{ status: number; body: any }> {
// board_manager_ids 필수값: 시드 게시판(free)의 관리자 uuid 를 재사용한다
// (프로필 엔드포인트는 user 타입 권한 게이트라 admin 토큰으로도 403 — 실측)
const freeBoard = await api(page, bearer, 'GET', `${API}/admin/boards/free`);
const managerUuid = (freeBoard.body?.data?.board_manager_ids ?? [])[0];
if (!managerUuid) {
throw new Error(`manager uuid unavailable: status=${freeBoard.status} body=${JSON.stringify(freeBoard.body).slice(0, 300)}`);
}
return api(page, bearer, 'POST', `${API}/admin/boards`, {
slug: SLUG,
name: { ko: '[검수용] 답글정책', en: 'Reply Policy E2E' },
type: 'basic',
use_reply: true,
show_view_count: true,
use_report: false,
board_manager_ids: [managerUuid],
...extra,
});
}
// 두 테스트가 같은 전용 게시판 slug 를 생성/삭제하므로 병렬 실행 시 경합한다 — 직렬 고정
test.describe.configure({ mode: 'serial' });
test.describe('게시판 답글 삭제 정책 (#573)', () => {
test('환경설정 답변글 탭에 정책 Select 가 렌더되고 기본값이 cascade 다', async ({ page, replyPolicyToken }) => {
await authenticatePage(page, replyPolicyToken);
// 답변글 서브탭은 basic_defaults 탭의 스크롤 섹션으로 함께 렌더된다 (TabNavigationScroll)
await page.goto('/admin/boards/settings?tab=basic_defaults');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 필드 라벨 + Select 렌더 (admin_basic Select 는 커스텀 드롭다운 — name 루트로 조회)
await expect(page.getByText('답변글 삭제 방식').first()).toBeVisible({ timeout: 15_000 });
// 저장값(기본 cascade)이 응답에 실려 있는지 API 로 대조
const settings = await api(page, replyPolicyToken, 'GET', `${API}/admin/settings`);
expect(settings.status).toBe(200);
const basicDefaults =
settings.body?.data?.settings?.basic_defaults ?? settings.body?.data?.basic_defaults ?? {};
expect(basicDefaults.reply_delete_policy ?? 'cascade').toBe('cascade');
});
test('게시판 편집 폼 저장 왕복 — block 저장 후 재조회에 반영된다', async ({ page, replyPolicyToken }) => {
await authenticatePage(page, replyPolicyToken);
await page.goto('/admin/boards');
await deleteBoardIfExists(page, replyPolicyToken);
// 전용 게시판 생성 (기본 cascade)
const created = await createBoard(page, replyPolicyToken);
expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status);
expect(created.body?.data?.reply_delete_policy ?? 'cascade').toBe('cascade');
// 편집 폼에서 필드 렌더 확인 (use_reply ON 조건부)
await page.goto(`/admin/boards/${SLUG}/edit`);
await expect(page.getByText('답변글 삭제 방식').first()).toBeVisible({ timeout: 20_000 });
// block 으로 저장 왕복 (API PUT — 폼과 동일 엔드포인트/검증 경로)
const updated = await api(page, replyPolicyToken, 'PUT', `${API}/admin/boards/${SLUG}`, {
reply_delete_policy: 'block',
});
expect(updated.status).toBe(200);
const fetched = await api(page, replyPolicyToken, 'GET', `${API}/admin/boards/${SLUG}`);
expect(fetched.body?.data?.reply_delete_policy).toBe('block');
// 허용 밖 값은 422 (닫힌 집합)
const invalid = await api(page, replyPolicyToken, 'PUT', `${API}/admin/boards/${SLUG}`, {
reply_delete_policy: 'evil',
});
expect(invalid.status).toBe(422);
await deleteBoardIfExists(page, replyPolicyToken);
});
test('block 게시판에서 답글 달린 글 삭제는 422 로 거부되고 DB 가 변하지 않는다', async ({ page, replyPolicyToken }) => {
await authenticatePage(page, replyPolicyToken);
await page.goto('/admin/boards');
await deleteBoardIfExists(page, replyPolicyToken);
const created = await createBoard(page, replyPolicyToken, { reply_delete_policy: 'block' });
expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status);
// 부모 글 + 답글 생성 (admin posts API)
const parent = await api(page, replyPolicyToken, 'POST', `${API}/admin/board/${SLUG}/posts`, {
title: '[검수용] 원글',
content: '답글 삭제 정책 검증용 원글입니다.',
});
expect([200, 201]).toContain(parent.status);
const parentId = parent.body?.data?.id;
expect(parentId).toBeTruthy();
const reply = await api(page, replyPolicyToken, 'POST', `${API}/admin/board/${SLUG}/posts`, {
title: 'Re: [검수용] 원글',
content: '답글 삭제 정책 검증용 답글입니다.',
parent_id: parentId,
});
expect([200, 201], `reply create failed: ${JSON.stringify(reply.body).slice(0, 400)}`).toContain(reply.status);
const replyId = reply.body?.data?.id;
// block: 원글 삭제 422 + 메시지
const blocked = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${parentId}`);
expect(blocked.status).toBe(422);
expect(String(blocked.body?.message ?? '')).toContain('답글');
// DB 무변화 — 원글이 여전히 조회된다
const stillThere = await api(page, replyPolicyToken, 'GET', `${API}/admin/board/${SLUG}/posts/${parentId}`);
expect(stillThere.status).toBe(200);
// 답글 먼저 삭제하면 원글 삭제가 허용된다
const replyDeleted = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${replyId}`);
expect(replyDeleted.status).toBe(200);
const parentDeleted = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${parentId}`);
expect(parentDeleted.status).toBe(200);
await deleteBoardIfExists(page, replyPolicyToken);
});
});
@@ -15,12 +15,14 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
* EcommerceInquiryHookListener 단위 테스트
*
* 검증 목적:
* - getSubscribedHooks: 7개 훅 등록, 모두 filter 타입
* - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null
* - getSubscribedHooks: 8개 훅 등록, 모두 filter 타입
* - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null,
* 기존 살아있는 답변 존재 시 중복 생성 차단(duplicate 마커), 답변 soft delete 후 재등록 허용
* - getByIds: 빈 배열 → carry 그대로, 유효 ID 목록 → 필드 포함 배열 반환
* - getBoardSettings: 존재하지 않는 slug → carry 그대로, 유효 slug → 설정 배열 반환
* - deletePost: 성공 → carry 반환, 존재하지 않는 PostId → RuntimeException
* - deletePost: 성공 → carry 반환, 답글 자식까지 cascade soft delete, 존재하지 않는 PostId → RuntimeException
* - deleteReplyPost: reply 없음 → RuntimeException, 성공 → carry 반환
* - countReplies: 살아있는 자식만 집계
*
* @group board
* @group unit
@@ -65,9 +67,9 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
// ==========================================
/**
* 7개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다.
* 8개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다.
*/
public function test_subscribed_hooks_registers_all_seven_filter_hooks(): void
public function test_subscribed_hooks_registers_all_eight_filter_hooks(): void
{
$hooks = EcommerceInquiryHookListener::getSubscribedHooks();
@@ -79,6 +81,7 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
'sirsoft-ecommerce.inquiry.delete_reply',
'sirsoft-ecommerce.inquiry.get_by_ids',
'sirsoft-ecommerce.inquiry.get_settings',
'sirsoft-ecommerce.inquiry.count_replies',
];
foreach ($expectedHooks as $hookName) {
@@ -86,7 +89,7 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSame('filter', $hooks[$hookName]['type'], "훅 {$hookName}은 filter 타입이어야 합니다.");
}
$this->assertCount(7, $hooks, '총 7개의 훅이 등록되어 있어야 합니다.');
$this->assertCount(8, $hooks, '총 8개의 훅이 등록되어 있어야 합니다.');
}
// ==========================================
@@ -216,6 +219,65 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertNull($result);
}
/**
* createAndReturn: 부모에 이미 살아있는 답변이 있으면 중복 생성 차단 → null 반환
*
* 2차 방어(게시판 실데이터 기준): 피벗 is_answered 플래그가 어긋난 경우에도
* 게시판에 중복 답변이 쌓이지 않아야 합니다.
*/
public function test_create_and_return_returns_duplicate_marker_when_parent_already_has_reply(): void
{
$parentPostId = $this->createTestPost(['title' => '원본 문의']);
$this->createTestPost([
'title' => 'Re: 원본 문의',
'parent_id' => $parentPostId,
'depth' => 1,
]);
$result = $this->listener->createAndReturn(null, $this->board->slug, [
'content' => '두 번째 답변 시도',
'parent_id' => $parentPostId,
'ip_address' => '127.0.0.1',
]);
// null(무응답/실패)이 아닌 중복 마커 — 호출 서비스가 "이미 등록된 답변" 사유로
// 변환한다. null 로 되돌리면 사유가 generic 실패로 위장된다 (PO 실측 제보 회귀).
$this->assertSame(
['duplicate' => true],
$result,
'살아있는 답변이 이미 있으면 중복 마커로 차단되어야 합니다.'
);
}
/**
* createAndReturn: 기존 답변이 soft delete 된 뒤에는 답변 재등록이 허용된다.
*
* 중복 차단 판정은 살아있는 답변만 대상으로 해야 합니다 (SoftDeletes 기본 스코프).
*/
public function test_create_and_return_allows_reply_after_previous_reply_soft_deleted(): void
{
$parentPostId = $this->createTestPost(['title' => '원본 문의']);
$replyPostId = $this->createTestPost([
'title' => 'Re: 원본 문의',
'parent_id' => $parentPostId,
'depth' => 1,
]);
// 기존 답변 soft delete
Post::find($replyPostId)->delete();
$result = $this->listener->createAndReturn(null, $this->board->slug, [
'content' => '삭제 후 재등록 답변',
'parent_id' => $parentPostId,
'ip_address' => '127.0.0.1',
]);
$this->assertIsArray($result, '기존 답변이 삭제된 뒤에는 답변 재등록이 허용되어야 합니다.');
$this->assertArrayHasKey('post_id', $result);
$this->assertIsInt($result['post_id']);
$this->assertNotSame($replyPostId, $result['post_id']);
}
// ==========================================
// getByIds
// ==========================================
@@ -384,6 +446,27 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSoftDeleted('board_posts', ['id' => $postId]);
}
/**
* deletePost: 부모 문의 삭제 시 답글 자식도 함께 cascade soft delete 된다.
*
* 훅 경유 삭제는 cascade_replies 옵션으로 게시판 답글 삭제 정책과 무관하게
* 시스템 생성 답변을 함께 정리해야 합니다.
*/
public function test_delete_post_soft_deletes_reply_children_too(): void
{
$parentPostId = $this->createTestPost(['title' => '원본 문의']);
$replyPostId = $this->createTestPost([
'title' => 'Re: 원본 문의',
'parent_id' => $parentPostId,
'depth' => 1,
]);
$this->listener->deletePost(null, $this->board->slug, $parentPostId);
$this->assertSoftDeleted('board_posts', ['id' => $parentPostId]);
$this->assertSoftDeleted('board_posts', ['id' => $replyPostId]);
}
/**
* deletePost: 존재하지 않는 postId → RuntimeException
*/
@@ -424,4 +507,34 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSame($carry, $result);
$this->assertSoftDeleted('board_posts', ['id' => $replyPostId]);
}
// ==========================================
// countReplies
// ==========================================
/**
* countReplies: 살아있는 자식만 집계한다 (soft delete 된 답변 제외).
*/
public function test_count_replies_counts_only_live_children(): void
{
$parentPostId = $this->createTestPost(['title' => '원본 문의']);
$this->createTestPost([
'title' => 'Re: 살아있는 답변',
'parent_id' => $parentPostId,
'depth' => 1,
]);
$trashedReplyId = $this->createTestPost([
'title' => 'Re: 삭제될 답변',
'parent_id' => $parentPostId,
'depth' => 1,
]);
Post::find($trashedReplyId)->delete();
$this->assertSame(
1,
$this->listener->countReplies(0, $parentPostId),
'soft delete 된 답변은 집계에서 제외되어야 합니다.'
);
}
}
@@ -0,0 +1,149 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Repositories;
// ModuleTestCase 수동 로드 (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use Modules\Sirsoft\Board\Repositories\PostRepository;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* PostRepository 답글 조회 메서드 테스트
*
* 검증 목적:
* - countRepliesByParentId: 살아있는 직계 자식만 집계 (SoftDeletes 기본 스코프)
* - hasAliveReplies: 살아있는 직계 답글 존재 여부 판정 (답글 삭제 정책 block 의 차단 기준)
* - 직계 살아있는 답글 존재 → true
* - 직계 답글 전부 trashed → false
* - 직계는 trashed 이고 손자만 살아있음(끊긴 체인 고아) → false (직계만 판정)
*
* @group board
* @group unit
* @group repository
*/
class PostRepositoryReplyLookupTest extends BoardTestCase
{
private PostRepository $repository;
protected function getTestBoardSlug(): string
{
return 'post-repository-reply-lookup';
}
protected function setUp(): void
{
parent::setUp();
$this->repository = app(PostRepository::class);
}
// ==========================================
// countRepliesByParentId
// ==========================================
/**
* countRepliesByParentId: 살아있는 자식만 집계한다 (trashed 자식 제외).
*/
public function test_count_replies_by_parent_id_counts_only_live_children(): void
{
$parentId = $this->createTestPost(['title' => '부모 글']);
$this->createTestPost([
'title' => '살아있는 답글',
'parent_id' => $parentId,
'depth' => 1,
]);
$this->createTestPost([
'title' => '삭제된 답글',
'parent_id' => $parentId,
'depth' => 1,
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => now()->subHour(),
]);
$this->assertSame(
1,
$this->repository->countRepliesByParentId($parentId),
'trashed 자식은 집계에서 제외되어야 합니다.'
);
}
// ==========================================
// hasAliveReplies
// ==========================================
/**
* hasAliveReplies: 살아있는 직계 답글이 있으면 true.
*/
public function test_has_alive_replies_returns_true_for_direct_live_reply(): void
{
$parentId = $this->createTestPost(['title' => '부모 글']);
$this->createTestPost([
'title' => '살아있는 답글',
'parent_id' => $parentId,
'depth' => 1,
]);
$this->assertTrue($this->repository->hasAliveReplies($this->board->slug, $parentId));
}
/**
* hasAliveReplies: 직계 답글이 전부 trashed 이면 false.
*/
public function test_has_alive_replies_returns_false_when_all_replies_trashed(): void
{
$parentId = $this->createTestPost(['title' => '부모 글']);
$this->createTestPost([
'title' => '삭제된 답글 1',
'parent_id' => $parentId,
'depth' => 1,
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => now()->subHour(),
]);
$this->createTestPost([
'title' => '삭제된 답글 2',
'parent_id' => $parentId,
'depth' => 1,
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => now()->subHour(),
]);
$this->assertFalse(
$this->repository->hasAliveReplies($this->board->slug, $parentId),
'직계 답글이 전부 trashed 이면 살아있는 답글이 없는 것으로 판정되어야 합니다.'
);
}
/**
* hasAliveReplies: 직계는 trashed 이고 손자만 살아있으면 false (직계만 판정).
*
* 끊긴 체인 밑 고아 손자는 별도로 cascade 스윕이 정리하며, block 정책 차단
* 판정은 직계 검사만으로 완결됩니다.
*/
public function test_has_alive_replies_returns_false_when_only_grandchild_alive(): void
{
$parentId = $this->createTestPost(['title' => '부모 글']);
$trashedChildId = $this->createTestPost([
'title' => '삭제된 직계 답글',
'parent_id' => $parentId,
'depth' => 1,
'status' => 'deleted',
'trigger_type' => 'user',
'deleted_at' => now()->subHour(),
]);
$this->createTestPost([
'title' => '살아있는 손자 답글',
'parent_id' => $trashedChildId,
'depth' => 2,
]);
$this->assertFalse(
$this->repository->hasAliveReplies($this->board->slug, $parentId),
'손자만 살아있는 경우 직계 판정은 false 여야 합니다.'
);
}
}
@@ -0,0 +1,52 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은
# PostService/PostRepository cascade + IndexBoardRequest 게이트를 PHPUnit Feature +
# Playwright E2E(admin/board-reply-delete-policy.spec.ts)가 커버하며 green.
feature: 게시판 답글 삭제 정책 (block/cascade) + 게시판 삭제 벌크 훅
description: |
답글이 달린 글의 삭제 방식을 게시판별 설정(reply_delete_policy)으로 선택한다.
cascade(기본): 원글 삭제 시 자손 답글 트리 전체가 trigger_type='cascade' 로 소프트
삭제되고(그 답글들의 댓글·첨부 포함), 복원 시 cascade 분만 top-down 선택 복원된다.
사용자가 직접 지운 답글 서브트리는 복원되지 않는다. block: 살아있는 직계 답글이
있으면 422 로 차단(before_delete 훅 발화 전 — 부수효과 0). 훅 경유 삭제
(cascade_replies 옵션)는 정책과 무관하게 cascade 고정. 게시판 삭제 시 삭제될 글
ID 를 board.posts.before_force_delete 벌크 훅으로 연동 확장에 통지한다.
관리자 게시판 목록의 정렬 컬럼·페이지 크기는 닫힌 집합으로만 허용된다.
axes:
policy:
- block
- cascade
delete_trigger:
- admin
- user
- ecommerce_hook # cascade_replies 옵션 — 정책 우회
- report # 신고 조치 삭제 — 정책 적용 유지
tree_state:
- single_reply
- deep_tree # 깊이 3 이상 중첩
- user_predeleted_mid # 중간 자손이 사용자 직접 삭제됨
- orphan_under_trashed # 끊긴 체인 밑 고아 답글
effects:
- block_returns_422_with_message # has_replies 메시지 + DB 무변화
- block_allows_when_replies_trashed # 답글 전부 trashed 면 삭제 허용
- cascade_marks_descendants_deleted # status='deleted' + trigger_type='cascade' + deleted_at
- cascade_children_comments_attachments # 자손 답글의 댓글·첨부도 cascade 소프트 삭제
- restore_top_down_cascade_only # 복원은 cascade 마킹분만 top-down
- user_predeleted_subtree_stays # 사용자 선삭제 서브트리는 복원 제외
- hook_delete_bypasses_block # cascade_replies 옵션이 block 정책 우회
- board_delete_fires_bulk_hook # before_force_delete 훅 chunk 발화 (withTrashed 전체)
- board_index_sort_whitelisted # sort_by 화이트리스트 밖 422, per_page 클램프
- settings_and_form_expose_policy # 환경설정/게시판 폼에 정책 Select 노출·저장 왕복
test_files:
- modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php
- modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php
- modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts
- modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx
- modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx
@@ -4,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.1] - 2026-08-12
## [1.1.1] - 2026-08-17
### Security
@@ -29,9 +29,20 @@
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
- 환불 예상금액 계산이 쿠폰 사용 상태를 일시적으로 변경하지 않습니다. 계산 과정의 쿠폰 상태를 참조하던 외부 확장이 있다면 동작을 확인해 주세요.
- 게시판에서 삭제한 문의 글을 복원하면 상품 문의 목록에도 함께 복원됩니다. 문의 데이터는 즉시 지워지지 않고 복원 가능한 상태로 보관됩니다.
- 업데이트 시, 과거에 문의가 삭제되면서 홀로 남아 있던 답변 글과 어긋난 답변완료 표시를 자동으로 정리합니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.)
### Fixed
#### 상품 문의 답변
- 이미 답변이 등록된 상품 문의에 답변을 다시 등록하면 답변이 중복으로 쌓이던 문제를 수정했습니다. 이제 안내와 함께 거절되며, 기존 답변을 수정하거나 삭제한 뒤 다시 등록할 수 있습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 상품 문의를 삭제해도 그 문의에 달린 답변 글이 게시판에 공개 상태로 남아 있던 문제를 수정했습니다. 이제 문의를 삭제하면 질문과 답변이 함께 정리됩니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 상품을 삭제해도 그 상품의 문의 글과 답변 글이 게시판에 공개 상태로 남아 있던 문제를 수정했습니다.
- 게시판에서 답변 글을 직접 삭제해도 상품 문의가 「답변완료」로 남아 있던 문제를 수정했습니다.
- 게시판에서 문의 글을 직접 삭제했을 때 큐 설정에 따라 문의 목록 정리가 지연되거나 누락될 수 있던 문제를 수정했습니다. 이제 삭제 즉시 반영됩니다.
- 문의의 답변 완료 시각이 이후 작업으로 덮어써질 수 있던 문제를 수정해 최초 답변 시각이 유지됩니다.
#### 카테고리 이미지
- 카테고리를 삭제해도 그 카테고리의 이미지 파일이 서버에 그대로 남던 문제를 수정했습니다. 상품 삭제와 동일하게 파일까지 함께 삭제됩니다.
@@ -49,6 +60,7 @@
#### 설정 저장 반영
- 관리자 화면(환경설정과 그 안의 모달·카테고리 순서 변경·주문 상세 정보 저장·쿠폰 발급/상태 변경)에서 저장·실행이 실패하면 실패 사유 대신 일반 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
- 관리자 환경설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 메타 정보가 계속 노출되던 문제를 수정했습니다. 쇼핑몰 SEO 설정 저장 시 관련 캐시가 즉시 정리됩니다.
- 환경설정 저장 직후 같은 요청 안에서 결제수단·통화 정보가 저장 전 값으로 계산되던 문제를 수정했습니다. (#116 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 배송정책을 저장·삭제하거나 기본 배송정책을 바꾼 직후, 같은 요청 안에서 변경 전 배송정책으로 배송비가 계산될 수 있던 문제를 수정했습니다.
@@ -122,6 +134,10 @@
- 관리자가 상점 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 주문서 작성 중 만들어지는 임시 주문 정보가 만료된 뒤에도 정리되지 않고 계속 쌓이던 문제를 수정했습니다 — 이제 매시간 자동으로 정리됩니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
#### 화면 표시
- 카테고리 관리 목록의 아이콘이 의도한 크기와 다르게 보이던 문제와, 쿠폰 상태 변경 확인 창의 버튼이 다크 모드에서 밝은 배경 그대로 보이던 문제를 수정했습니다.
## [1.1.0] - 2026-08-10
### Security
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 상품 문의 피벗 테이블에 소프트 삭제 컬럼을 추가합니다.
*
* 게시판에서 질문 글을 삭제했다가 복원하는 경로에서 피벗도 함께 복원되어야
* 하는데, 종전에는 피벗이 하드 삭제라 복원이 불가능했습니다(#107 후속).
* 상품 자체가 영구 삭제되는 경로만 피벗도 영구 삭제합니다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('ecommerce_product_inquiries')
|| Schema::hasColumn('ecommerce_product_inquiries', 'deleted_at')) {
return;
}
Schema::table('ecommerce_product_inquiries', function (Blueprint $table) {
$table->softDeletes()->comment('소프트 삭제 일시');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('ecommerce_product_inquiries')
|| ! Schema::hasColumn('ecommerce_product_inquiries', 'deleted_at')) {
return;
}
Schema::table('ecommerce_product_inquiries', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};
@@ -163,11 +163,14 @@ class ProductInquirySeeder extends Seeder
*/
private function deleteExistingInquiries(Board $board): void
{
$existingCount = ProductInquiry::count();
// SoftDeletes 도입 여파: 소프트 삭제된 피벗 잔존물까지 포함해 전량 재시드한다
$existingCount = ProductInquiry::withTrashed()->count();
if ($existingCount > 0) {
// 연결된 게시판 Post도 함께 삭제
$inquirableIds = ProductInquiry::where('inquirable_type', Post::class)->pluck('inquirable_id');
$inquirableIds = ProductInquiry::withTrashed()
->where('inquirable_type', Post::class)
->pluck('inquirable_id');
if ($inquirableIds->isNotEmpty()) {
Post::whereIn('id', $inquirableIds)->forceDelete();
@@ -178,7 +181,7 @@ class ProductInquirySeeder extends Seeder
->whereNotNull('parent_id')
->forceDelete();
ProductInquiry::query()->delete();
ProductInquiry::withTrashed()->forceDelete();
$this->command->warn("기존 문의 데이터 {$existingCount}건을 삭제했습니다.");
}
@@ -70,7 +70,7 @@ _단건 응답: `data` 객체의 필드._
<!-- @generated:end -->
**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 해당 문의를 제거하고 `{deleted: true}` 를 반환합니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `ProductInquiryOperationException` 을 던지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다.
**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 게시판 훅으로 질문 게시글을 소프트 삭제한 뒤 문의 피벗(`product_inquiries`)도 **소프트 삭제**하고 `{deleted: true}` 를 반환합니다. 피벗이 소프트 삭제로 유지되므로, 이후 게시판 관리 화면에서 질문 게시글을 복원하면 문의 피벗도 함께 복원되어 문의 목록에 다시 나타납니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `ProductInquiryOperationException` 을 던지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다.
### DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply
@@ -126,7 +126,7 @@ _단건 응답: `data` 객체의 필드._
<!-- @generated:end -->
**설명** 관리자가 문의에 등록된 답변을 삭제합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::deleteReply()` 가 답변을 제거하고 문의를 미답변 상태로 되돌린 뒤 `{deleted: true}` 를 반환합니다. 문의 자체는 유지되며, 잘못 등록한 답변을 회수할 때 사용합니다.
**설명** 관리자가 문의에 등록된 답변을 삭제합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::deleteReply()` 가 답변을 제거한 뒤 필터 훅 `sirsoft-ecommerce.inquiry.count_replies`(carry: int, 초기값 `0` / 인자: 부모 문의 Post ID / 반환: 살아있는 답변 수)로 잔여 답변 수를 재확인하여, **살아있는 답변이 0건일 때만** 문의를 미답변 상태(`is_answered=false`)로 되돌리고 `{deleted: true}` 를 반환합니다 — 과거 결함으로 답변이 여러 건 쌓인 문의에서 일부만 삭제해도 답변완료 표기가 유지됩니다. 문의 자체는 유지되며, 잘못 등록한 답변을 회수할 때 사용합니다.
### POST /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply
@@ -185,12 +185,21 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.` — `board_not_configured`) / 이미 답변이 등록된 문의에 재등록을 시도한 경우(`이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요.` — `reply_already_exists`) / 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) |
**오류 응답 예시** (이미 답변된 문의에 재등록 시 — 422)
```json
{
"success": false,
"message": "이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요."
}
```
<!-- @generated:end -->
**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 이미 답변이 있는 등 등록 불가 상황에서는 `ProductInquiryOperationException` 이 던져지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자가 고객 문의에 응대할 때 사용합니다.
**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 문의당 답변은 1건만 유지되는 단일 답변 정책이라, 이미 답변완료(`is_answered=true`)인 문의에 재등록을 시도하면 `reply_already_exists` 사유의 422 로 거절됩니다 — 기존 답변을 수정(PUT)하거나 삭제(DELETE) 후 다시 등록해야 합니다. 문의 게시판이 미설정인 경우에도 `board_not_configured` 사유의 422 가 반환됩니다. 관리자가 고객 문의에 응대할 때 사용합니다.
### PUT /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply
@@ -458,7 +467,7 @@ _단건 응답: `data` 객체의 필드._
<!-- @generated:end -->
**설명** 로그인 회원이 자신이 작성한 문의를 삭제합니다. `auth:sanctum` 인증이 필요하며, 컨트롤러가 문의를 조회해 없으면 404, `inquiry->user_id` 가 로그인 사용자와 다르면 403 을 반환한 뒤 `ProductInquiryService::deleteInquiry()` 로 삭제하고 `{deleted: true}` 를 반환합니다. 마이페이지에서 본인 문의를 취소/삭제할 때 사용합니다.
**설명** 로그인 회원이 자신이 작성한 문의를 삭제합니다. `auth:sanctum` 인증이 필요하며, 컨트롤러가 문의를 조회해 없으면 404, `inquiry->user_id` 가 로그인 사용자와 다르면 403 을 반환한 뒤 `ProductInquiryService::deleteInquiry()` 로 삭제하고 `{deleted: true}` 를 반환합니다. 관리자 삭제 경로와 마찬가지로 문의 피벗은 **소프트 삭제**되며, 게시판에서 질문 게시글이 복원되면 문의도 함께 복원됩니다. 마이페이지에서 본인 문의를 취소/삭제할 때 사용합니다.
### PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}
@@ -584,7 +593,7 @@ _단건 응답: `data` 객체의 필드._
<!-- @generated:end -->
**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의 답변을 삭제합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::deleteReply()` 로 답변을 제거하고 `{deleted: true}` 를 반환합니다. 답변 권한을 위임받은 사용자(예: 상담원 역할)가 사용자 화면에서 답변을 회수할 때 사용합니다.
**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의 답변을 삭제합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::deleteReply()` 로 답변을 제거하고 `{deleted: true}` 를 반환합니다. 관리자 경로와 동일하게 필터 훅 `sirsoft-ecommerce.inquiry.count_replies`(carry: int, 초기값 `0` / 인자: 부모 문의 Post ID / 반환: 살아있는 답변 수)로 잔여 답변 수를 재확인해 0건일 때만 미답변 상태로 되돌립니다. 답변 권한을 위임받은 사용자(예: 상담원 역할)가 사용자 화면에서 답변을 회수할 때 사용합니다.
### POST /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply
@@ -643,12 +652,12 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.` — `board_not_configured`) / 이미 답변이 등록된 문의에 재등록을 시도한 경우(`이미 등록된 답변이 있습니다. 기존 답변을 수정하거나 삭제한 후 다시 등록해주세요.` — `reply_already_exists`) / 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) |
<!-- @generated:end -->
**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의에 답변을 등록합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료로 전환해 `{id, is_answered}` 를 201 로 반환합니다. 관리자 화면이 아닌 사용자 프론트에서 답변을 처리하는 상담원 역할에 사용합니다.
**설명** 사용자 표면에서 문의 답변 권한을 가진 회원이 문의에 답변을 등록합니다. `auth:sanctum` 인증에 더해 컨트롤러가 `PermissionHelper::check('sirsoft-ecommerce.inquiries.update')` 로 답변 권한을 확인(없으면 403)한 뒤 `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료로 전환해 `{id, is_answered}` 를 201 로 반환합니다. 관리자 경로와 동일한 단일 답변 정책이 적용되어, 이미 답변완료인 문의에 재등록하면 `reply_already_exists` 사유의 422, 문의 게시판 미설정이면 `board_not_configured` 사유의 422 로 거절됩니다. 관리자 화면이 아닌 사용자 프론트에서 답변을 처리하는 상담원 역할에 사용합니다.
### PUT /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId}/reply
@@ -93,6 +93,20 @@ describe('환경설정 탭별 개별 저장 패턴 검증', () => {
expect(body).toContain("'basic_info'");
});
it('저장 실패 토스트가 서버 오류 메시지를 노출해야 한다 (회귀 — PO 실측 제보)', () => {
// 고정 문구만 쓰면 검증 실패 사유(예: 필드별 422 메시지)가 사용자에게
// 도달하지 못하고 "설정 저장에 실패했습니다" 로 뭉개진다.
const apiCallAction = findActionByHandler(saveButton.actions, 'apiCall');
const errorToast = (apiCallAction.onError ?? []).find(
(a: any) => a.handler === 'toast',
);
expect(errorToast).toBeDefined();
expect(errorToast.params.message).toContain('error.message');
// 서버 메시지 부재 시 기존 고정 문구로 폴백
expect(errorToast.params.message).toContain('save_error');
});
it('body 에 _tab 메타 필드가 포함되어야 한다', () => {
const apiCallAction = findActionByHandler(saveButton.actions, 'apiCall');
const body = apiCallAction.params.body;
@@ -590,7 +590,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-ecommerce.admin.settings.save_error"
"message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.save_error')}}"
}
}
]
@@ -30,7 +30,7 @@
"name": "Icon",
"props": {
"name": "folder",
"className": "w-5 h-5 text-gray-700 dark:text-gray-300"
"className": "text-xl text-gray-700 dark:text-gray-300"
}
},
{
@@ -139,7 +139,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-ecommerce.admin.category.messages.order_update_failed"
"message": "{{error.message ?? '$t:sirsoft-ecommerce.admin.category.messages.order_update_failed'}}"
}
},
{
@@ -220,7 +220,7 @@
"name": "Icon",
"props": {
"name": "folder",
"className": "w-5 h-5 text-gray-400 dark:text-gray-500"
"className": "text-xl text-gray-400 dark:text-gray-500"
}
},
{
@@ -2284,7 +2284,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-ecommerce.admin.order.detail.order_info.save_error"
"message": "{{error.message ?? '$t:sirsoft-ecommerce.admin.order.detail.order_info.save_error'}}"
}
}
]
@@ -76,7 +76,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-ecommerce.admin.promotion_coupon.modal.direct_issue.search_failed"
"message": "{{error.message ?? '$t:sirsoft-ecommerce.admin.promotion_coupon.modal.direct_issue.search_failed'}}"
}
}
]
@@ -61,7 +61,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isChangingStatus}}"
},
"actions": [
@@ -126,7 +126,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-ecommerce.common.error.general"
"message": "{{error.message ?? '$t:sirsoft-ecommerce.common.error.general'}}"
}
}
]
@@ -460,7 +460,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-ecommerce.admin.settings.save_error"
"message": "{{error.message ?? $t('sirsoft-ecommerce.admin.settings.save_error')}}"
}
}
]

Some files were not shown because too many files have changed in this diff Show More