chore(board,basic,core,admin_basic): 게시판 반응(추천/비추천) 기능 및 비즈뿌리오 메시징 플러그인 제거

게시판 게시글 반응 기능과 비즈뿌리오(카카오 알림톡·문자) 메시징 플러그인을 제거한다.

게시판 반응
- 마이그레이션 5종(반응 유형·반응·게시판 사용여부·활성 유형·게시글 카운트), 시더,
 모델·저장소·서비스·컨트롤러·검증 규칙·예외·API 리소스 제거
- 관리자 게시판 폼/환경설정의 반응 사용 토글과 유형 선택 블록, 반응 유형 데이터소스 제거
- 사용자 게시글 상세의 반응 버튼 영역 제거 (반응 도입 이전 상태로 복원)
- 반응 관련 다국어 키(ko/en/ja), API 문서, 시나리오 매니페스트, 단위·기능·E2E 테스트 제거

비즈뿌리오 메시징 플러그인
- 플러그인 본체와 일본어 번들 언어팩 제거
- 코어 문서·빌드 스크립트·검사 baseline, 게시판·이커머스 CHANGELOG 에 남아 있던 참조 정리

존치 항목
- 게시판·이커머스 알림 설정 행 하단의 확장 슬롯(notification_definition_row_footer)은
 코어 알림 설정 화면과 동일한 범용 확장점이라 유지한다
- 사용자 템플릿의 PostReactions 컴포넌트는 반응 기능 도입 이전부터 존재하던 자산이라 유지한다
- 태그 입력 드롭다운이 하단 고정 버튼에 가려지던 수정은 반응 기능과 무관해 유지한다

부수 정정
- 라우팅 판정 diff 스냅샷 총량을 실측값으로 갱신한다. 기록값 463 은 제거 이전 시점에도
 실제(471)와 어긋나 있었고, 이번 제거 후 실측은 469 다.
This commit is contained in:
HeuJung
2026-08-10 16:28:03 +09:00
parent f70c41c9ee
commit d3a62a7e04
232 changed files with 25 additions and 26042 deletions
+3 -4
View File
@@ -155,7 +155,7 @@
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -168,12 +168,11 @@
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 7 / 13 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
| `sirsoft-tosspayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 2 / 3 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
-1
View File
@@ -240,7 +240,6 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
@@ -8,8 +8,6 @@
### Added
- 게시글 반응(추천/비추천) 기능의 안내·검증·활동 로그·관리자 설정 문구 일본어 번역 추가 — 반응 등록/변경/취소 안내, 반응 비활성화·본인 글·비활성 유형·열람 권한 없는 비밀글 차단 안내, 반응 사용 토글·유형 선택 설정 라벨이 일본어 로케일에서 자연스럽게 표시됩니다.
- 게시판 환경설정 일괄 적용 화면의 "반응 사용" 항목 라벨 일본어 번역 추가.
- 첨부파일 개수 상한 초과 안내 문구 일본어 번역 추가.
- 게시판 대시보드 조회 안내 문구 일본어 번역 추가.
- 관리자 게시글 작성·수정 화면의 첨부 안내 문구 일본어 번역 추가 — 게시판에 설정된 첨부 개수·용량이 일본어 로케일에서도 그대로 표시됩니다.
@@ -18,9 +18,6 @@ return [
'update' => '編集',
'update_status' => 'ステータス変更',
'upload' => 'アップロード',
'add' => 'リアクション登録',
'change' => 'リアクション変更',
'remove' => 'リアクション取消',
],
'description' => [
'board_index' => '掲示板一覧閲覧',
@@ -60,9 +57,6 @@ return [
'board_settings_bulk_apply' => '掲示板設定一括適用',
'board_settings_bulk_apply_aborted' => '掲示板設定一括適用中止(全体ロールバック、失敗掲示板::failed_board_name、:failed_at/:total)',
'board_type_show' => '掲示板タイプ詳細閲覧 (:type_name)',
'reaction_add' => 'リアクション登録 (掲示板: :board_name, タイトル: :title, タイプ: :reaction_type)',
'reaction_change' => 'リアクション変更 (掲示板: :board_name, タイトル: :title, タイプ: :reaction_type)',
'reaction_remove' => 'リアクション取消 (掲示板: :board_name, タイトル: :title)',
],
'fields' => [
'name' => '掲示板名',
@@ -289,16 +289,4 @@ return [
'dashboard' => [
'fetch_success' => 'ダッシュボードデータを閲覧しました。',
],
'reaction' => [
'list_success' => 'リアクションタイプのリストを閲覧しました。',
'add_success' => 'リアクションを追加しました。',
'change_success' => 'リアクションを変更しました。',
'remove_success' => 'リアクションをキャンセルしました。',
'failed' => 'リアクション処理に失敗しました。',
'disabled' => 'この掲示板はリアクション機能が無効化されています。',
'inactive_type' => 'この掲示板では使用できないリアクションタイプです。',
'self_post' => '自分の投稿にはリアクションできません。',
'login_required' => 'ログインが必要な機能です。',
'secret_denied' => '閲覧権限がない秘密投稿には反応できません。',
],
];
@@ -286,8 +286,6 @@ return [
'spam_security.comment_cooldown_seconds' => 'コメント作成クールダウン(秒)',
'spam_security.report_cooldown_seconds' => '通報クールダウン(秒)',
'spam_security.view_count_cache_ttl' => '閲覧数キャッシュ有効期限(秒)',
'basic_defaults.use_reaction' => 'リアクション機能を使用',
'basic_defaults.active_reaction_types' => '使用するリアクションタイプ',
],
'post' => [
'title' => 'タイトル',
@@ -352,9 +350,6 @@ return [
'override_values.max_file_count' => '最大ファイル個数',
'override_values.new_display_hours' => '新規表示時間',
],
'reaction' => [
'reaction_type_id' => 'リアクションタイプ',
],
],
'blind' => [
'reason' => [
@@ -531,10 +526,4 @@ return [
'daily_limit_exceeded' => '本日の通報可能回数(:limit回)を超過しました。',
'rejection_limit_exceeded' => '最近 :days 日間の通報却下が :count 回累積され、通報が制限されました。',
],
'reaction' => [
'reaction_type_id' => [
'required' => 'リアクションタイプは必須です。',
'integer' => 'リアクションタイプは整数である必要があります。',
],
],
];
@@ -18,8 +18,7 @@
"reporters_list": "通報者一覧",
"reports": "通報一覧",
"roles": "ロール一覧",
"settings": "掲示板設定",
"reactionTypes": "レスポンスタイプ"
"settings": "掲示板設定"
}
},
"common": {
@@ -128,9 +128,7 @@
"channels_label": "通知チャネル",
"channel_mail": "メール",
"channel_database": "サイト通知"
},
"use_reaction": "リアクション使用",
"active_reaction_types": "使用するリアクションタイプを選択"
}
},
"placeholders": {
"name": "例:お知らせ",
@@ -148,8 +146,7 @@
"use_comment": "有効化するとコメント関連の詳細設定(長さ、深さ、並べ替え)を指定できます。",
"use_reply": "有効化すると回答投稿の最大深さを設定できます。",
"use_report": "不適切な投稿/コメントの通報機能を有効化します",
"type": "掲示板の種類は環境設定 > 基本設定で管理できます。",
"use_reaction": "投稿に推薦/非推薦などのリアクションを残す機能を有効化します"
"type": "掲示板の種類は環境設定 > 基本設定で管理できます。"
},
"options": {
"secret_mode": {
@@ -175,11 +175,8 @@
"use_file_upload": "ONに設定する必要があって、新しい掲示板にファイル添付機能が適用されます。",
"max_file_size": "アップロード可能な最大ファイルサイズです (MB単位、{{min}}~{{max}})",
"max_file_count": "投稿あたり添付可能な最大ファイル数です ({{min}}~{{max}})",
"allowed_extensions": "許可するファイル拡張子を最低1つ以上入力してください",
"use_reaction": "投稿に推薦/非推薦などのリアクションを残す機能を有効化します"
},
"use_reaction": "リアクション使用",
"active_reaction_types": "使用するリアクションタイプを選択"
"allowed_extensions": "許可するファイル拡張子を最低1つ以上入力してください"
}
},
"options": {
"date_display_format": {
@@ -301,8 +298,7 @@
"notify_author": "投稿者通知",
"new_display_hours": "新着表示時間",
"default_board_permissions": "デフォルト権限",
"manager": "掲示板管理",
"use_reaction": "リアクション使用"
"manager": "掲示板管理"
},
"select_boards_hint": "下記から一括適用する掲示板を検索して選択してください。"
},
@@ -1,11 +0,0 @@
# Changelog
이 언어팩의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-28
### Added
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다.
@@ -1,6 +0,0 @@
<?php
return [
'action' => [],
'description' => [],
];
@@ -1,85 +0,0 @@
<?php
return [
'channel' => [
'sms' => 'SMS',
'lms' => 'LMS',
'alimtalk' => 'アラートトーク',
],
'status' => [
'pending' => '待機中',
'sent' => '送信中',
'success' => '成功',
'failed' => '失敗',
],
'source' => [
'auto' => '自動',
'manual' => '手動',
'bulk' => '一括',
],
'result_category' => [
'success' => '成功',
'retry' => '再試行',
'permanent_failure' => '永久失敗',
'balance_low' => '残高不足',
],
'channels' => [
'source_label' => 'ビズプリオ',
'sms' => [
'name' => 'SMS/LMSテキスト',
'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
],
'alimtalk' => [
'name' => 'カカオアラートトーク',
'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
],
],
'readiness' => [
'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。',
'sms_sender_number_missing' => '発信番号を設定してください。',
'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。',
'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。',
],
'settings' => [
'bizppurio_id_attribute' => 'ビズプリオアイディ',
'password_attribute' => 'パスワード',
'sender_number_attribute' => '発信番号',
],
'webhook' => [
'received' => 'レポートを受け取りました。',
],
'error' => [
'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
'send_failed' => 'メッセージ送信リクエストに失敗しました。',
'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
'invalid_response' => 'ビズプリオレスポンスを解析できません。',
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
],
'send_skipped' => [
'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)',
'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)',
'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
],
'binding' => [
'saved' => 'アラートトーク連携を保存しました。',
'removed' => 'アラートトーク連携を解除しました。',
],
'cache' => [
'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。',
],
'channel_group' => [
'text' => '文字',
'alimtalk' => '通知トーク',
],
'token_check' => [
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
],
];
@@ -1,55 +0,0 @@
<?php
return [
'1000' => '成功',
'2000' => 'メッセージが無効です',
'3001' => '認証情報が無効です(Basic)',
'3002' => 'トークンが無効です(期限切れ·廃止)',
'3003' => 'IPが無効です',
'3004' => 'アカウントが無効です',
'3005' => '認証情報が無効です(Bearer)',
'3006' => 'アカウントが存在しません',
'3007' => 'アカウントパスワードが無効です',
'3009' => 'アカウントが停止状態です',
'3010' => 'アクセス許可IPが一致しません',
'3011' => '不明なエラー(bizppurio)',
'3013' => '完了処理されていないメッセージ',
'4100' => '成功',
'4400' => '電波の弱い地域',
'4401' => '電源オフ',
'4402' => 'ストレージ超過',
'4410' => '不正な番号',
'4414' => '結番·停止',
'4420' => 'その他端末エラー',
'4430' => 'スパム',
'4431' => '送信制限受信拒否(スパム)',
'4443' => 'スパムブロック',
'5002' => 'リクエストが多すぎます',
'5003' => '一時的な送信エラー',
'5004' => '一時的な送信エラー',
'5005' => '一時的な送信エラー',
'6600' => '成功',
'6603' => '電波の弱い地域',
'6604' => '電源オフ',
'6606' => '不正な番号',
'6621' => 'メッセージ長超過',
'6641' => 'スパムブロック',
'7000' => '成功',
'7103' => '発信プロフィールキーが無効です',
'7106' => '削除された発信キー',
'7107' => 'ブロックされた発信キー',
'7204' => 'メッセージ内容がテンプレートと不一致です',
'7206' => 'シリアルナンバー形式が不一致です',
'7306' => 'カカオシステムエラー',
'7307' => '処理遅延',
'7308' => '電話番号エラー',
'7320' => '受信ブロック',
'7325' => '変数長超過',
'7421' => 'タイムアウト',
'7436' => 'ウォレット残高不足(アラートトーク)',
'7437' => 'メッセージリクエスト失敗',
'7523' => '080受信拒否(スパム)',
'9000' => '一時的なシステムエラー',
'9070' => '残高不足(SMS)',
'9071' => '後払い限度額超過',
];
@@ -1,227 +0,0 @@
{
"name": "ビズプリオ メッセージ発送",
"description": "ビズプリオ 連動 SMS/LMS・カカオ アラート톡 発送プラグインです。",
"settings": {
"title": "ビズプリオ メッセージ発送 設定",
"description": "ビズプリオ 連動情報と発送 設定を管理します。",
"save": "保存",
"saving": "保存 中...",
"save_success": "設定が保存されました。",
"save_failed": "設定 保存に失敗しました。",
"test_mode": {
"label": "検査モード",
"hint": "検査モードでは ビズプリオ 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
"account_notice": "検査と運用は別の ビズプリオ アカウントで運営することをお勧めします。"
},
"live_mode_warning_title": "運用環境で発送されます",
"live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラート톡が発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
"sections": {
"api": {
"title": "API 連動",
"description": "発送システムとカカオ管理システムに共通に使用する連動情報です。"
},
"sending": {
"title": "発送 設定",
"description": "文字・アラート톡発送に使用する発信情報です。"
}
},
"fields": {
"bizppurio_id": {
"label": "ビズプリオ ID",
"hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。"
},
"password": {
"label": "ビズプリオモジュールパスワード",
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
},
"api_key": {
"label": "API キー",
"hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
},
"sender_number": {
"label": "発信番号",
"hint": "文字・アラート톡発送に使用する発信電話番号です。"
},
"sender_key": {
"label": "アラート톡 発信プロファイル キー",
"hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。"
},
"template_cache_minutes": {
"label": "アラート톡 内容 キャッシュ 時間(分)",
"hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。",
"clear_cache": "キャッシュ 初期化",
"clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。",
"clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。",
"clear_cache_failed": "キャッシュ 初期化に失敗しました。"
},
"connection_check": {
"label": "接続確認",
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
"button": "接続確認",
"checking": "確認中...",
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
"failed": "認証確認に失敗しました。",
"unsaved_changes": "変更内容を先に保存してください。"
}
},
"report": {
"section_title": "レポート受信 設定",
"hint": "ビズプリオ は文字・アラート톡発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
"note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
"copy": "コピー",
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
},
"cache": {
"section_title": "アラート톡 発送内容 キャッシュ",
"section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。"
},
"tabs": {
"connection": "環境設定",
"templates": "アラート톡 テンプレート"
},
"preparation": {
"intro": "文字・カカオ アラート톡を発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
"sms_label": "文字(SMS/LMS)",
"sms_sender": "発信番号を登録してください。",
"kakao_label": "カカオ アラート톡",
"kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。",
"kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。",
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
"console_link": "ビズプリオ コンソールを開く"
}
},
"binding": {
"section_title": "カカオ アラート톡 連動",
"list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。",
"section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。",
"modal_title": "アラート톡 接続 · {name}",
"unbound": "未接続",
"unavailable": "使用不可 — 再接続が必要",
"btn_connect": "接続",
"btn_change": "接続を変更",
"fallback_on": "SMS 代替 ON",
"fallback_off": "SMS 代替 OFF",
"connected_template": "接続テンプレート",
"none": "接続しない",
"no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。",
"templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。",
"fallback_sms": "失敗時 SMS で代替発送",
"fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。",
"variables_hint": "提供変数(発送時に自動置換)",
"saved": "アラート톡 連動を保存しました。",
"save_error": "アラート톡 連動 保存に失敗しました。"
},
"banner": {
"not_ready": "発送に必要な 設定が完了していません。",
"setup_action": "設定する",
"test_mode": "検査モードです — 実際の発送は行われません。"
},
"dispatch_result": {
"column_header": "文字・アラート톡 結果",
"inspection_label": "検査",
"low_balance": "残高不足",
"fallback": "SMS 代替発送 {status}",
"detail_title": "文字・アラート톡 発送 結果",
"channel_label": "発送チャネル: {channel}",
"sent_content_label": "実際の発送内容",
"sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。"
},
"editor": {
"data_source": {
"dispatch_results": "ビズプリオ送信結果"
}
},
"templates": {
"title": "アラームトークテンプレート",
"description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。",
"readiness": {
"title": "アラームトークテンプレートを使用するには、以下の設定が必要です",
"go_settings": "環境設定に移動",
"missing_label": "未設定項目",
"api_key_missing": "カカオ管理API キー",
"sender_key_missing": "アラームトーク送信プロフィールキー",
"note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。"
},
"list_error": {
"title": "テンプレート一覧を読み込めませんでした"
},
"list_notice": {
"console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。",
"console_link": "ビズプリオコンソールを開く"
},
"list": {
"refresh": "更新",
"search": "検索",
"search_placeholder": "テンプレート名検索(2~50文字)",
"filter_all": "すべてのステータス",
"empty": "登録されたアラームトークテンプレートがありません。",
"empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。",
"load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。",
"columns": {
"no": "番号",
"name": "テンプレート名",
"code": "コード",
"status": "ステータス",
"requested_at": "登録申請日",
"processed_at": "処理日",
"actions": "内容"
}
},
"status": {
"sendable": "送信可能",
"inspecting": "審査中",
"rejected": "却下",
"uninspected": "未審査",
"stopped": "停止",
"blocked": "ブロック",
"dormant": "休止",
"unknown": "不明"
},
"status_sub": {
"rdy": "(使用前)"
},
"status_guide": {
"title": "ステータスバッジ",
"sendable_label": "送信可能",
"sendable": "承認完了。このステータスのみアラームに接続·送信できます。",
"inspecting_label": "審査中",
"inspecting": "カカオ審査進行中(営業日2~3日)。",
"pending_label": "未審査·却下",
"pending": "まだ送信できません。コンソールで審査申請·修正してください。"
},
"link_type": {
"WL": "ウェブリンク",
"AL": "アプリリンク",
"DS": "配送追跡",
"BK": "ボットキーワード",
"MD": "メッセージ転送",
"AC": "チャネル追加",
"BC": "相談トーク転換",
"BT": "ボット転換",
"TN": "電話をかける",
"MP": "地図表示",
"P1": "画像セキュア送信",
"P2": "個人情報利用",
"P3": "ワンクリック決済"
},
"actions": {
"detail": "詳細"
},
"detail": {
"title": "テンプレート詳細",
"close": "閉じる",
"buttons": "ボタン",
"extra": "付加情報",
"category": "カテゴリ",
"code": "テンプレートコード",
"content": "テンプレート内容",
"emphasize_type": "テンプレート種別",
"image_upload": "画像添付",
"subtitle_field": "補助文句",
"title_field": "強調表記文句",
"type_image": "画像形",
"type_none": "基本形",
"type_text": "強調表記形"
}
}
}
@@ -1,30 +0,0 @@
{
"identifier": "g7-plugin-sirsoft-message_bizppurio-ja",
"namespace": "g7",
"vendor": "sirsoft",
"name": {
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩",
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack",
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック"
},
"description": {
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)",
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-message_bizppurio",
"locale": "ja",
"locale_name": "Japanese",
"locale_native_name": "日本語",
"text_direction": "ltr",
"g7_version": ">=7.0.0-beta.4",
"requires": {
"target_version": null,
"depends_on_core_locale": true
},
"github_url": "",
"github_changelog_url": ""
}
@@ -1,4 +0,0 @@
{
"name": "Bizppurio メッセージ発送",
"description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。"
}
@@ -1,18 +0,0 @@
{
"bizppurio_balance_low": {
"definition": {
"name": "Bizssprrio残高不足",
"description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます"
},
"templates": {
"mail": {
"subject": "[{app_name}] Bizssprrio 残高が不足しています",
"body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。"
},
"database": {
"subject": "Bizssprrio残高不足",
"body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。"
}
}
}
}
@@ -1,18 +0,0 @@
{
"sirsoft-message_bizppurio": {
"name": "ビズプリオ メッセージ発送",
"description": "ビズプリオ メッセージ発送プラグインが提供する権限"
},
"sirsoft-message_bizppurio.messaging": {
"name": "メッセージ発送",
"description": "メッセージ発送ドメイン権限 (閲覧·管理)"
},
"sirsoft-message_bizppurio.messaging.view": {
"name": "メッセージ閲覧",
"description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)"
},
"sirsoft-message_bizppurio.messaging.manage": {
"name": "メッセージ管理",
"description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
}
}
@@ -20,12 +20,10 @@
### Added
- 게시글 반응(추천/비추천) 버튼의 로그인 필요 안내·처리 실패 안내 문구(`board.reaction.login_required`, `board.reaction.failed`) 일본어 번역 추가 — 반응 버튼 조작 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
- 모달 닫기 버튼의 안내 라벨(`common.close_modal`) 일본어 번역 추가 — 화면 낭독기 사용자에게 버튼 용도가 일본어로 안내됩니다.
- 검색 결과 건수 표기와 검색어 구체화 안내(`search.result_count_suffix_at_least`, `search.refine_query_hint`) 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기가 일본어 로케일에서 자연스럽게 표시됩니다.
- 편집기 화면 문구(`editor.*`) 일본어 번역 보강.
- 본인 글에 반응 버튼을 눌렀을 때의 안내 문구(`board.reaction.self_post_denied`) 일본어 번역 추가 — 본인 글 반응 시도 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.1] - 2026-07-08
@@ -137,10 +137,7 @@
"laugh": "面白い",
"agree": "同意します",
"thanks": "ありがとうございます",
"wow": "驚きました",
"login_required": "ログインが必要な機能です",
"failed": "応答処理に失敗しました。しばらく後にもう一度お試しください。",
"self_post_denied": "自分の投稿には反応できません。"
"wow": "驚きました"
},
"attachments": {
"title": "添付ファイル",
@@ -13,10 +13,8 @@
### Added
- 게시글에 추천·비추천 반응을 남길 수 있는 기능을 추가했습니다. 로그인한 회원은 게시글 상세에서 반응을 남기거나 다른 반응으로 바꾸거나 취소할 수 있고, 각 반응의 개수가 항상 함께 표시됩니다. 본인이 쓴 글에는 반응할 수 없으며, 열람 권한이 없어 본문을 볼 수 없는 비밀글에는 반응 버튼이 표시되지 않고 반응도 남길 수 없습니다. 게시판 관리 설정에서 반응 사용 여부와 사용할 반응 유형(추천·비추천)을 게시판별로 켜고 끌 수 있으며, 새 게시판을 만들 때 적용될 기본값도 환경설정에서 지정합니다.
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
### Changed
@@ -19,8 +19,6 @@
"comment_order": "ASC",
"show_view_count": true,
"use_report": false,
"use_reaction": true,
"active_reaction_types": ["like"],
"min_title_length": 2,
"max_title_length": 200,
"min_content_length": 2,
@@ -1,46 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 게시판 반응 유형 테이블 생성
*
* 반응(추천/비추천 등) 유형을 DB로 관리한다 (Enum 코드 고정 아님).
* 향후 커뮤니티별 커스텀 명칭·관리자 CRUD 확장을 위한 구조 확보.
*/
public function up(): void
{
Schema::create('board_reaction_types', function (Blueprint $table) {
$table->id()->comment('반응 유형 ID');
$table->string('code', 50)->unique()->comment('내부 식별자 (예: like, dislike)');
$table->text('name')->comment('다국어 라벨 JSON ({"ko":"추천","en":"Recommend","ja":"おすすめ"})');
$table->string('icon')->nullable()->comment('Font Awesome 아이콘 클래스 (예: fas fa-thumbs-up)');
$table->unsignedInteger('display_order')->default(0)->comment('표시 순서');
$table->boolean('is_active')->default(true)->comment('활성 여부 (완전 삭제 미지원, 비활성화만 가능)');
$table->text('user_overrides')->nullable()->comment('사용자 수정 보존 필드 (언어팩 시드 머지 시 사용자 편집값 유지)');
$table->timestamps();
$table->index('is_active', 'idx_reaction_type_active');
$table->index('display_order', 'idx_reaction_type_order');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('board_reaction_types', function (Blueprint $table) {
$table->comment('게시판 반응 유형 (추천/비추천 등)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('board_reaction_types');
}
};
@@ -1,52 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 게시판 반응 이력 테이블 생성
*
* 사용자당 대상(게시글)에 1행. 등록=INSERT, 전환=UPDATE, 해제=DELETE.
* 신고(boards_reports)처럼 target_type/target_id 폴리모픽 구조를 따르되,
* 케이스/로그 2테이블로 나누지 않고 1테이블로 처리한다 (반응엔 처리 상태 개념 없음).
*/
public function up(): void
{
Schema::create('board_reactions', function (Blueprint $table) {
$table->id()->comment('반응 이력 ID');
$table->unsignedBigInteger('user_id')->comment('반응한 사용자 ID');
$table->string('target_type', 20)->comment('반응 대상 타입 (현재 post, 향후 comment 확장 가능)');
$table->unsignedBigInteger('target_id')->comment('반응 대상 ID (동적 테이블 ID, FK 없이 앱 레벨 무결성 관리)');
$table->unsignedBigInteger('reaction_type_id')->comment('반응 유형 ID');
$table->unsignedBigInteger('board_id')->nullable()->comment('게시판 ID (게시판 삭제 시 NULL)');
$table->timestamps();
$table->unique(['user_id', 'target_type', 'target_id'], 'unique_user_target_reaction');
$table->index(['target_type', 'target_id'], 'idx_reaction_target');
$table->index('reaction_type_id', 'idx_reaction_type');
$table->index('board_id', 'idx_reaction_board');
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('reaction_type_id')->references('id')->on('board_reaction_types')->restrictOnDelete();
$table->foreign('board_id')->references('id')->on('boards')->nullOnDelete();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('board_reactions', function (Blueprint $table) {
$table->comment('게시판 반응 이력 (사용자+대상당 1행)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('board_reactions');
}
};
@@ -1,41 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* boards 테이블에 반응 사용 여부 컬럼 추가
*
* use_comment/use_report와 동일 패턴 — 게시판별 반응 기능 on/off.
*/
public function up(): void
{
Schema::table('boards', function (Blueprint $table) {
if (! Schema::hasColumn('boards', 'use_reaction')) {
$table->boolean('use_reaction')
->default(true)
->after('use_report')
->comment('반응(추천/비추천) 사용 여부');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('boards')) {
$columns = Schema::getColumnListing('boards');
Schema::table('boards', function (Blueprint $table) use ($columns) {
if (in_array('use_reaction', $columns)) {
$table->dropColumn('use_reaction');
}
});
}
}
};
@@ -1,43 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* boards 테이블에 활성 반응 유형 목록 컬럼 추가
*
* 게시판별로 켠 반응 유형의 code(문자열) 목록을 JSON 배열로 저장한다.
* 예: ["like","dislike"]. ID가 아닌 code로 저장하는 이유는 시더 실행 순서에
* 따라 ID가 환경마다 달라질 수 있어서다 (설정값은 사람이 읽는 code로 통일).
*/
public function up(): void
{
Schema::table('boards', function (Blueprint $table) {
if (! Schema::hasColumn('boards', 'active_reaction_types')) {
$table->text('active_reaction_types')
->nullable()
->after('use_reaction')
->comment('활성화된 반응 유형 code 목록 JSON 배열 (예: ["like","dislike"])');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('boards')) {
$columns = Schema::getColumnListing('boards');
Schema::table('boards', function (Blueprint $table) use ($columns) {
if (in_array('active_reaction_types', $columns)) {
$table->dropColumn('active_reaction_types');
}
});
}
}
};
@@ -1,43 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* board_posts 테이블에 반응 카운트 통합 컬럼 추가
*
* 유형별 반응 개수를 JSON 하나로 통합 저장한다. 키는 유형 ID 문자열.
* 예: {"1":18,"2":2}. 라벨/code가 바뀌어도 카운트가 끊기지 않도록 ID를 키로 쓴다.
* 2026_04_17_000002 패턴(hasColumn 가드 + after + 한국어 comment + down) 재사용.
*/
public function up(): void
{
Schema::table('board_posts', function (Blueprint $table) {
if (! Schema::hasColumn('board_posts', 'reaction_counts')) {
$table->text('reaction_counts')
->nullable()
->after('attachments_count')
->comment('반응 유형별 개수 JSON (키는 유형 ID, 예: {"1":18,"2":2})');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_posts')) {
$columns = Schema::getColumnListing('board_posts');
Schema::table('board_posts', function (Blueprint $table) use ($columns) {
if (in_array('reaction_counts', $columns)) {
$table->dropColumn('reaction_counts');
}
});
}
}
};
@@ -1,90 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use App\Concerns\Seeder\HasTranslatableSeeder;
use App\Contracts\Seeder\TranslatableSeederInterface;
use App\Extension\Helpers\GenericEntitySyncHelper;
use Illuminate\Database\Seeder;
use Modules\Sirsoft\Board\Models\ReactionType;
/**
* 게시판 반응 유형 초기 시더.
*
* 관리자 CRUD 화면이 없으므로(이슈 #525 확정 01) 이 시더가 유일한 유형 등록 경로다.
* 라벨 기본값은 ko/en/ja를 직접 하드코딩하되, 활성 언어팩의 seed/reaction_types.json
* 다국어 키를 trait 이 자동 머지한다 (BoardTypeSeeder 와 동일 패턴).
*
* code로 매칭하는 upsert(user_overrides 보존)라 재실행해도 중복 생성되지 않는다.
*/
class BoardReactionTypeSeeder extends Seeder implements TranslatableSeederInterface
{
use HasTranslatableSeeder;
public function getExtensionIdentifier(): string
{
return 'sirsoft-board';
}
public function getTranslatableEntity(): string
{
return 'reaction_types';
}
public function getMatchKey(): string
{
return 'code';
}
/**
* @return array<int, array<string, mixed>>
*/
public function getDefaults(): array
{
return [
[
'code' => 'like',
'name' => ['ko' => '추천', 'en' => 'Recommend', 'ja' => 'おすすめ'],
'icon' => 'fas fa-thumbs-up',
'display_order' => 1,
'is_active' => true,
],
[
'code' => 'dislike',
'name' => ['ko' => '비추천', 'en' => 'Not Recommend', 'ja' => 'ひどい'],
'icon' => 'fas fa-thumbs-down',
'display_order' => 2,
'is_active' => true,
],
];
}
/**
* 시더 실행
*/
public function run(): void
{
$helper = app(GenericEntitySyncHelper::class);
foreach ($this->resolveTranslatedDefaults() as $reactionType) {
$existing = ReactionType::where('code', $reactionType['code'])->exists();
$helper->sync(
ReactionType::class,
['code' => $reactionType['code']],
[
'name' => $reactionType['name'],
'icon' => $reactionType['icon'],
'display_order' => $reactionType['display_order'],
'is_active' => $reactionType['is_active'],
],
);
if ($existing) {
$this->command->info(" 반응 유형 '{$reactionType['code']}' 동기화 (사용자 수정 보존).");
} else {
$this->command->info(" 반응 유형 '{$reactionType['code']}' 생성 완료.");
}
}
}
}
@@ -44,10 +44,9 @@ class DatabaseSeeder extends Seeder
$this->command->info('');
// 설치 필수 시더 (항상 실행)
$this->command->info('[설치] 게시판 타입 / 반응 유형 생성');
$this->command->info('[설치] 게시판 타입 생성');
$this->call([
BoardTypeSeeder::class,
BoardReactionTypeSeeder::class,
]);
$this->command->info('');
@@ -15,7 +15,6 @@
| [boards.md](boards.md) | `boards` | 37 |
| [dashboard.md](dashboard.md) | `dashboard` | 4 |
| [my-comments.md](my-comments.md) | `my-comments` | 1 |
| [reaction.md](reaction.md) | `reaction` | 2 |
| [reports.md](reports.md) | `reports` | 7 |
| [settings.md](settings.md) | `settings` | 5 |
| [users.md](users.md) | `users` | 2 |
@@ -893,8 +893,6 @@ _단건 응답: `data` 객체의 필드._
| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). |
| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
| is_already_reported | boolean | `false` | already reported 여부 |
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
@@ -1057,8 +1055,6 @@ _단건 응답: `data` 객체의 필드._
| attachments | array | `[{"id":155,"hash":"apidocsmpl1","original_filename":"apid…` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). 비밀글은 열람 권한이 없으면 빈 배열, 삭제된 게시글은 관리 권한이 없으면 연쇄 삭제된 첨부만 노출됩니다. |
| replies | array | `[]` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
| is_already_reported | boolean | `false` | already reported 여부 |
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
@@ -1316,8 +1312,6 @@ _단건 응답: `data` 객체의 필드._
| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). |
| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
| is_already_reported | boolean | `false` | already reported 여부 |
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
@@ -1477,8 +1471,6 @@ _단건 응답: `data` 객체의 필드._
| attachments | null | `null` | 게시글 첨부파일 목록(AttachmentResource 컬렉션). attachments 관계가 로드된 경우에만 채워지며, 아니면 null(비밀글·삭제글은 권한에 따라 빈 배열 또는 연쇄 삭제분만 노출). |
| replies | null | `null` | 이 게시글에 달린 답변글 목록(PostResource 컬렉션, 재귀). replies 관계가 로드된 경우에만 채워지며, 아니면 null. |
| is_already_reported | boolean | `false` | already reported 여부 |
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
| abilities | object | `{"can_read":true,"can_write":true,"can_read_secret":true,…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
@@ -2086,8 +2078,6 @@ _단건 응답: `data` 객체의 필드._
| is_author | boolean | `true` | author 여부 |
| is_guest_comment | boolean | `false` | guest comment 여부 |
| is_already_reported | boolean | `false` | already reported 여부 |
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 반응 유형별 개수 맵(JSON 객체). 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. board 관계 미로드 시 빈 객체(`{}`). |
| my_reaction_type_id | integer\|null | `1` | 현재 로그인 사용자가 이 게시글에 남긴 반응 유형 ID. 반응이 없으면 null. |
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
| abilities | object | `{"can_read":true,"can_write":true,"can_manage":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
@@ -134,8 +134,6 @@ HTTP/1.1 200
| use_comment | body | boolean | 예 | — | 댓글 기능 사용 여부 |
| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 |
| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 |
| use_reaction | body | boolean | 예 | — | 게시글 반응(추천/비추천) 기능 사용 여부 |
| active_reaction_types | body | array | 아니오 | — | 이 게시판에서 켠 반응 유형 code 목록 (문자열 배열, 예: `["like","dislike"]`) |
| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) |
| new_display_hours | body | integer | 아니오 | min 0, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일). `0` 은 NEW 배지를 표시하지 않음 |
| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 |
@@ -396,8 +394,6 @@ _단건 응답: `data` 객체의 필드._
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| show_view_count | boolean | `true` | 조회수 노출 |
| use_report | boolean | `false` | 게시글/댓글 신고 기능 사용 |
| use_reaction | boolean | `true` | 게시글 반응(추천/비추천) 기능 사용 |
| active_reaction_types | array | `["like","dislike"]` | 이 게시판에서 켠 반응 유형 code 목록. 생성 모드에서는 환경설정 기본값(`basic_defaults.active_reaction_types`)이 채워짐 |
| min_title_length | integer | `2` | 최소 제목 글자 수 |
| max_title_length | integer | `200` | 최대 제목 글자 수 |
| min_content_length | integer | `2` | 최소 게시글 글자 수 |
@@ -1255,8 +1251,6 @@ HTTP/1.1 200
| use_comment | body | boolean | 예 | — | 댓글 기능 사용 여부 |
| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 |
| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 |
| use_reaction | body | boolean | 예 | — | 게시글 반응(추천/비추천) 기능 사용 여부 |
| active_reaction_types | body | array | 아니오 | — | 이 게시판에서 켠 반응 유형 code 목록 (문자열 배열, 예: `["like","dislike"]`) |
| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) |
| new_display_hours | body | integer | 아니오 | min 0, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일). `0` 은 NEW 배지를 표시하지 않음 |
| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 |
@@ -1,135 +0,0 @@
# Reaction API 레퍼런스
> **소유**: module `sirsoft-board` · 게시글 반응(추천/비추천) 관련 엔드포인트 레퍼런스입니다.
---
## TL;DR (5초 요약)
```text
1. 게시글 반응(추천/비추천) 등록·전환·해제를 단일 POST 엔드포인트로 처리합니다
2. 반응은 로그인 회원만 가능하며(auth:sanctum), 본인 글에는 반응할 수 없습니다
3. 글당 반응 1개 — 같은 유형 재요청은 해제, 다른 유형은 전환(이전 -1·신규 +1)
4. 관리자 반응 유형 목록 API는 게시판 설정 화면의 유형 체크박스 옵션 소스입니다
5. 유형 CRUD 는 이번 범위 밖 — 유형은 시더/스크립트로 관리합니다
```
---
## POST /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/react
게시글에 반응(추천/비추천)을 남깁니다. 등록·전환·해제를 한 엔드포인트가 통합 처리합니다.
- **라우트명**: `api.modules.sirsoft-board.boards.posts.react`
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\ReactionController@react`
- **인증/권한**: `auth:sanctum` (로그인 회원 전용)
**경로 파라미터**
| 이름 | 위치 | 타입 | 필수 | 용도 |
| --- | --- | --- | --- | --- |
| slug | path | string | 예 | 게시판 slug |
| postId | path | integer | 예 | 반응 대상 게시글 ID (해당 게시판 소속이어야 함) |
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 용도 |
| --- | --- | --- | --- | --- |
| reaction_type_id | body | integer | 예 | 반응 유형 ID. 존재하는 활성 유형이면서 게시판이 켠(활성) 유형이어야 합니다. |
**요청 예시**
```http
POST /api/modules/sirsoft-board/boards/free/posts/42/react HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"reaction_type_id": 1
}
```
**동작**
| 상황 | 결과 (`data.action`) | 카운트 변화 |
| --- | --- | --- |
| 기존 반응 없음 | `add` | 해당 유형 +1 |
| 기존 반응이 다른 유형 | `change` | 이전 유형 -1 · 신규 유형 +1 |
| 기존 반응이 같은 유형 | `remove` | 해당 유형 -1 (이력 행 삭제) |
**응답 필드** (`data` 내부)
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| action | string | `add` | 수행된 동작 (`add`/`change`/`remove`) |
| my_reaction_type_id | integer\|null | `1` | 처리 후 내가 누른 반응 유형 ID. 해제 시 `null`. |
| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 유형 전체의 개수 맵. 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. |
**응답 예시**
```json
{
"success": true,
"message": "반응을 남겼습니다.",
"data": {
"action": "add",
"my_reaction_type_id": 1,
"reaction_counts": { "1": 18, "2": 2 }
}
}
```
**오류**
| 상태 | 사유 |
| --- | --- |
| 401 | 비로그인 요청 |
| 404 | 게시글이 해당 게시판 소속이 아니거나 존재하지 않음 |
| 422 | 반응 기능이 꺼진 게시판 / 게시판이 켜지 않은 유형 / 본인 글 반응 / `reaction_type_id` 검증 실패 |
---
## GET /api/modules/sirsoft-board/admin/reaction-types
활성 반응 유형 전체를 `display_order` 순으로 반환합니다. 게시판 설정 화면의 "사용할 반응 유형" 체크박스 옵션 소스입니다. 유형 CRUD 는 제공하지 않습니다.
- **라우트명**: `api.modules.sirsoft-board.admin.reaction-types.index`
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReactionTypeController@index`
- **인증/권한**: `auth:sanctum` + `admin` + `permission:sirsoft-board.settings.read`
**요청 예시**
```http
GET /api/modules/sirsoft-board/admin/reaction-types HTTP/1.1
Host: api.example.com
Accept: application/json
Accept-Language: ko
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data.reaction_types[]` 배열 항목)
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| id | integer | `1` | 반응 유형 ID |
| code | string | `like` | 내부 식별자 (예: `like`/`dislike`) |
| name | string | `추천` | 현재 로케일 라벨. 미입력 언어는 사이트 기본 언어로 폴백됩니다. |
| icon | string\|null | `fas fa-thumbs-up` | 저장된 원본 Font Awesome 클래스 (스타일 접두사 포함) |
| icon_name | string\|null | `fa-thumbs-up` | Icon 컴포넌트 `name` prop 용 토큰 (스타일 접두사 제거). Icon 컴포넌트가 접두사를 자체 부착하므로 `icon` 을 그대로 넘기면 접두사가 중복됩니다. |
**응답 예시**
```json
{
"success": true,
"message": "반응 유형 목록을 조회했습니다.",
"data": {
"reaction_types": [
{ "id": 1, "code": "like", "name": "추천", "icon": "fas fa-thumbs-up", "icon_name": "fa-thumbs-up" },
{ "id": 2, "code": "dislike", "name": "비추천", "icon": "fas fa-thumbs-down", "icon_name": "fa-thumbs-down" }
]
}
}
```
@@ -361,7 +361,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.use_reaction`(boolean) 과 `basic_defaults.active_reaction_types`(반응 유형 code 문자열 배열)를 포함하며, 새 게시판 생성 시 이 값이 개별 게시판의 초기값으로 시드됩니다. `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 | 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` 을 허용합니다. |
| 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` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) |
@@ -1,70 +0,0 @@
// e2e:allow 반응 사용 토글 + 유형 체크박스 저장은 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(토글/체크/저장)으로 커버. 게시판 환경설정 화면 Playwright 인프라 부재
/**
* 게시판 반응(추천/비추천) 설정 UI 회귀 가드 (이슈 #525 확정 02·11·13)
*
* @description
* 고정 대상:
* 1) 환경설정 기본값 탭(_tab_board_settings_basic)에 use_reaction 토글이 있고
* basic_defaults.use_reaction 에 바인딩된다 (확정 13, 새 게시판 기본값).
* 2) 유형 체크박스 서브패널은 use_reaction on 일 때만 노출되며,
* reactionTypes 데이터소스를 iteration 해 basic_defaults.active_reaction_types(code 배열)를
* 토글한다 (확정 02·11, DB 유형 목록 동적 렌더).
* 3) 개별 게시판 편집 폼(admin_board_form/_tab_basic)에도 동일 구조가 use_reaction /
* active_reaction_types 에 바인딩되어 존재한다 (게시판별 override).
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import settingsTab from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json';
import formTab from '../../../layouts/admin/partials/admin_board_form/_tab_basic.json';
const settingsJson = JSON.stringify(settingsTab);
const formJson = JSON.stringify(formTab);
describe('환경설정 기본값 탭 — 반응 사용 토글 + 유형 체크박스', () => {
// @scenario case=settings_toggle_and_checkbox_persist
// @effects settings_toggle_persists_use_reaction
it('use_reaction 토글이 basic_defaults.use_reaction 에 바인딩된다', () => {
expect(/"name":\s*"basic_defaults\.use_reaction"/.test(settingsJson)).toBe(true);
});
it('유형 체크박스 서브패널은 use_reaction on 일 때만 노출된다', () => {
expect(/_local\.form\?\.basic_defaults\?\.use_reaction === true/.test(settingsJson)).toBe(true);
});
it('reactionTypes 데이터소스를 iteration 해 유형을 동적 렌더한다', () => {
expect(/reactionTypes\?\.data\?\.reaction_types/.test(settingsJson)).toBe(true);
expect(/"item_var":\s*"reactionType"/.test(settingsJson)).toBe(true);
});
// @scenario case=settings_toggle_and_checkbox_persist
// @effects settings_checkbox_persists_active_reaction_types
it('체크박스가 active_reaction_types(code 배열) 를 include/exclude 로 토글한다', () => {
// 체크 상태: code 포함 여부
expect(
/\(_local\.form\?\.basic_defaults\?\.active_reaction_types \?\? \[\]\)\.includes\(reactionType\.code\)/.test(
settingsJson,
),
).toBe(true);
// 저장: 포함 시 filter 제거, 미포함 시 spread 추가 (upsert 토글)
expect(/"form\.basic_defaults\.active_reaction_types"/.test(settingsJson)).toBe(true);
expect(/filter\(c => c !== reactionType\.code\)/.test(settingsJson)).toBe(true);
});
});
describe('개별 게시판 편집 폼 — 반응 사용 토글 + 유형 체크박스 (override)', () => {
it('use_reaction 토글이 use_reaction 에 바인딩된다', () => {
expect(/"name":\s*"use_reaction"/.test(formJson)).toBe(true);
});
it('유형 체크박스는 use_reaction on 일 때만 노출되고 active_reaction_types 를 토글한다', () => {
expect(/_local\.form\?\.use_reaction === true/.test(formJson)).toBe(true);
expect(/reactionTypes\?\.data\?\.reaction_types/.test(formJson)).toBe(true);
expect(
/\(_local\.form\?\.active_reaction_types \?\? \[\]\)\.includes\(reactionType\.code\)/.test(formJson),
).toBe(true);
expect(/"form\.active_reaction_types"/.test(formJson)).toBe(true);
});
});
@@ -5,7 +5,7 @@
*
* @description
* 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면,
* 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* 확장이 이 슬롯에 주입하는 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*
@@ -2,7 +2,6 @@
"editor": {
"data_source": {
"availableChannels": "Available Channels",
"reactionTypes": "Reaction Types",
"boardIdentityPolicies": "Board Identity Policies",
"boardIdentityPurposes": "Board Identity Purposes",
"boardNotificationDefinitions": "Board Notification Definitions",
@@ -2,7 +2,6 @@
"editor": {
"data_source": {
"availableChannels": "사용 가능 채널",
"reactionTypes": "반응 유형",
"boardIdentityPolicies": "게시판 본인인증 정책",
"boardIdentityPurposes": "게시판 본인인증 목적",
"boardNotificationDefinitions": "게시판 알림 정의",
@@ -23,8 +23,6 @@
"use_comment": "Enable Comments",
"use_reply": "Enable Reply Posts",
"use_report": "Enable Reports",
"use_reaction": "Enable Reactions",
"active_reaction_types": "Select Reaction Types",
"secret_mode": "Secret Mode",
"order_by": {
"label": "Sort By",
@@ -148,7 +146,6 @@
"use_comment": "When enabled, you can configure detailed comment settings (length, depth, order).",
"use_reply": "When enabled, you can set the maximum reply depth.",
"use_report": "Enable report feature for inappropriate posts/comments",
"use_reaction": "Enable reactions such as recommend/not recommend on posts",
"type": "Board types can be managed in Settings > Basic Settings."
},
"options": {
@@ -95,8 +95,6 @@
"notify_author": "Author Notification",
"notify_author_description": "Send email to the original author when a comment or reply is posted",
"use_report": "Enable Reports",
"use_reaction": "Enable Reactions",
"active_reaction_types": "Select Reaction Types",
"auto_hide_threshold": "Auto Blind Threshold",
"auto_hide_threshold_hint": "Automatically blind content when reports reach this count. (0=disabled, 1\u2013100)",
"auto_hide_target": "Blind Target",
@@ -157,7 +155,6 @@
"secret_mode": "Set the default behavior for the secret post feature",
"show_view_count": "Display view count on post list and detail pages",
"use_report": "Enable reporting of inappropriate posts and comments",
"use_reaction": "Enable reactions such as recommend\/not recommend on posts",
"new_display_hours": "Duration to display [New] badge after posting (in hours)",
"per_page": "Number of posts to display per page on desktop ({{min}}–{{max}})",
"per_page_mobile": "Number of posts to display per page on mobile ({{min}}–{{max}})",
@@ -283,7 +280,6 @@
"secret_mode": "Secret Mode",
"use_comment": "Enable Comments",
"use_reply": "Enable Replies",
"use_reaction": "Enable Reactions",
"max_reply_depth": "Max Reply Depth",
"max_comment_depth": "Max Comment Depth",
"comment_order": "Comment Sort Order",
@@ -23,8 +23,6 @@
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"use_report": "신고 사용",
"use_reaction": "반응 사용",
"active_reaction_types": "사용할 반응 유형 선택",
"secret_mode": "비밀글 모드",
"order_by": {
"label": "정렬 기준",
@@ -148,7 +146,6 @@
"use_comment": "활성화하면 댓글 관련 세부 설정(길이, 깊이, 정렬)을 지정할 수 있습니다.",
"use_reply": "활성화하면 답변글 최대 깊이를 설정할 수 있습니다.",
"use_report": "부적절한 게시글/댓글 신고 기능을 활성화합니다",
"use_reaction": "게시글에 추천/비추천 등 반응을 남기는 기능을 활성화합니다",
"type": "게시판 유형은 환경설정 > 기본설정에서 관리할 수 있습니다."
},
"options": {
@@ -95,8 +95,6 @@
"notify_author": "작성자 알림",
"notify_author_description": "댓글, 답변글 작성 시 원글 작성자에게 이메일 발송",
"use_report": "신고 사용",
"use_reaction": "반응 사용",
"active_reaction_types": "사용할 반응 유형 선택",
"auto_hide_threshold": "자동 숨김 기준 (회)",
"auto_hide_threshold_hint": "이 횟수 이상 신고가 접수되면 자동으로 블라인드 처리됩니다. (0=비활성, 1~100)",
"auto_hide_target": "적용 대상",
@@ -157,7 +155,6 @@
"secret_mode": "비밀글 기능의 기본 동작을 설정합니다",
"show_view_count": "게시글 목록 및 상세에서 조회수를 표시합니다",
"use_report": "부적절한 게시글\/댓글 신고 기능을 활성화합니다",
"use_reaction": "게시글에 추천\/비추천 등 반응을 남기는 기능을 활성화합니다",
"new_display_hours": "게시글 작성 후 [New] 배지를 표시할 기간 (시간 단위)",
"per_page": "PC 환경에서 한 페이지에 표시할 게시글 수입니다 ({{min}}~{{max}})",
"per_page_mobile": "모바일 환경에서 한 페이지에 표시할 게시글 수입니다 ({{min}}~{{max}})",
@@ -283,7 +280,6 @@
"secret_mode": "비밀글 모드",
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"use_reaction": "반응 사용",
"max_reply_depth": "답변글 최대 깊이",
"max_comment_depth": "대댓글 최대 깊이",
"comment_order": "댓글 정렬 순서",
@@ -82,20 +82,6 @@
"channels": []
}
}
},
{
"id": "reactionTypes",
"label_key": "$t:sirsoft-board.editor.data_source.reactionTypes",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reaction-types",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": {
"reaction_types": []
}
}
}
],
"slots": {
@@ -158,21 +158,6 @@
}
}
},
{
"id": "reactionTypes",
"label_key": "$t:sirsoft-board.editor.data_source.reactionTypes",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reaction-types",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": {
"reaction_types": []
}
}
},
{
"id": "boardIdentityPolicies",
"label_key": "$t:sirsoft-board.editor.data_source.boardIdentityPolicies",
@@ -2,7 +2,7 @@
"meta": {
"is_partial": true,
"description": "Board Form - Basic Tab Content",
"_e2e_allow": "e2e:allow 반응 유형 체크박스 시각 스타일(배경 박스·들여쓰기·아이콘·정렬)만 변경, 클릭 동작(setState/hasChanges)은 기존과 동일. 렌더 분기·바인딩은 admin-board-reaction-settings.test.tsx(Vitest 레이아웃 테스트)로 커버, MCP 실브라우저로 시각 회귀 확인."
"_e2e_allow": "e2e:allow 반응 설정 블록 제거 — 기능 자체가 사라져 검증 대상이 없다. 남은 필드의 렌더·바인딩은 변경 없음."
},
"id": "section_basic_card",
"type": "basic",
@@ -674,138 +674,6 @@
}
}
]
},
{
"id": "field_use_reaction",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-start justify-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.fields.use_reaction"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.use_reaction"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "use_reaction",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 active_reaction_types(code 배열) 를 토글. 게시판 환경설정 화면과 동일하게 배경 박스 + 좌측 들여쓰기로 시각적 구분 (PO 피드백).",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.use_reaction === true}}",
"props": {
"className": "ml-6 mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mb-2"
},
"text": "$t:sirsoft-board.admin.form.fields.active_reaction_types"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{reactionTypes?.data?.reaction_types ?? []}}",
"item_var": "reactionType",
"index_var": "reactionIdx"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"disabled": "{{_computed.isReadOnly}}",
"checked": "{{(_local.form?.active_reaction_types ?? []).includes(reactionType.code)}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.active_reaction_types": "{{(_local.form?.active_reaction_types ?? []).includes(reactionType.code) ? (_local.form?.active_reaction_types ?? []).filter(c => c !== reactionType.code) : [...(_local.form?.active_reaction_types ?? []), reactionType.code]}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{reactionType.icon_name ?? 'fa-thumbs-up'}}",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{reactionType.name ?? reactionType.code}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -2,7 +2,7 @@
"meta": {
"is_partial": true,
"description": "게시판 설정 > 기본 탭 (기본 설정 + 일괄 적용)",
"_e2e_allow": "e2e:allow 반응 유형 체크박스 시각 스타일(배경 박스·들여쓰기·아이콘·정렬)만 변경, 클릭 동작(setState/hasChanges)은 기존과 동일. 렌더 분기·바인딩은 admin-board-reaction-settings.test.tsx(Vitest 레이아웃 테스트)로 커버, MCP 실브라우저로 시각 회귀 확인."
"_e2e_allow": "e2e:allow 반응 설정 블록 제거 — 기능 자체가 사라져 검증 대상이 없다. 남은 필드의 렌더·바인딩은 변경 없음."
},
"id": "basic",
"type": "basic",
@@ -471,163 +471,6 @@
]
}
]
},
{
"id": "field_use_reaction",
"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-heading flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('use_reaction')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_reaction') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_reaction') : [...(_local.bulkApplyFields ?? []), 'use_reaction']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.use_reaction"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_reaction"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.use_reaction",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"comment": "사용할 반응 유형 선택 — 반응 사용 on 일 때만 노출. DB 등록 유형 전체(reactionTypes)를 순회해 체크박스로 basic_defaults.active_reaction_types(code 배열) 를 토글. 좌측 일괄적용 체크박스(bulkApplyFields)와 형태가 같아 혼동되기 쉬우므로, 배경 박스 + 좌측 들여쓰기로 '설정값 입력 영역'임을 시각적으로 분리한다 (PO 피드백).",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.basic_defaults?.use_reaction === true}}",
"props": {
"className": "row-content-indent ml-6 mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint mb-2"
},
"text": "$t:sirsoft-board.admin.settings.fields.active_reaction_types"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{reactionTypes?.data?.reaction_types ?? []}}",
"item_var": "reactionType",
"index_var": "reactionIdx"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"disabled": "{{_computed.isReadOnly}}",
"checked": "{{(_local.form?.basic_defaults?.active_reaction_types ?? []).includes(reactionType.code)}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.basic_defaults.active_reaction_types": "{{(_local.form?.basic_defaults?.active_reaction_types ?? []).includes(reactionType.code) ? (_local.form?.basic_defaults?.active_reaction_types ?? []).filter(c => c !== reactionType.code) : [...(_local.form?.basic_defaults?.active_reaction_types ?? []), reactionType.code]}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{reactionType.icon_name ?? 'fa-thumbs-up'}}",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{reactionType.name ?? reactionType.code}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -1,91 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Exceptions;
use App\Helpers\ResponseHelper;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 반응 불가 예외
*
* 반응 기능이 꺼진 게시판, 게시판이 켜지 않은(비활성) 유형, 본인 글 반응 시도 등
* 반응이 허용되지 않는 상황에서 발생합니다 (이슈 #525 확정 07·08·11).
*
* `ReactPostRequest` + `AvailableReactionType` Rule 이 요청 단계에서 선차단하지만,
* 훅이나 Service 직접 호출처럼 FormRequest 를 거치지 않는 경로가 있으므로 최종
* 불변조건은 Service 가 보장합니다. 사용자가 고칠 수 있는 상태 문제이므로 422 로 매핑합니다.
*/
class ReactionNotAllowedException extends Exception
{
/**
* @param string $reason 거절 사유 (disabled | inactive_type | self_post | secret_denied)
* @param string $message 사용자에게 보일 메시지
*/
public function __construct(
private string $reason,
string $message,
) {
parent::__construct($message);
}
/**
* 반응 기능이 꺼진 게시판에 대한 예외를 생성합니다.
*
* @return self 반응 비활성화 사유 예외
*/
public static function disabled(): self
{
return new self('disabled', __('sirsoft-board::messages.reaction.disabled'));
}
/**
* 게시판이 켜지 않은(비활성) 유형에 대한 예외를 생성합니다.
*
* @return self 비활성 유형 사유 예외
*/
public static function inactiveType(): self
{
return new self('inactive_type', __('sirsoft-board::messages.reaction.inactive_type'));
}
/**
* 본인 글 반응 시도에 대한 예외를 생성합니다.
*
* @return self 본인 글 사유 예외
*/
public static function selfPost(): self
{
return new self('self_post', __('sirsoft-board::messages.reaction.self_post'));
}
/**
* 열람 권한이 없는 비밀글 반응 시도에 대한 예외를 생성합니다.
*
* @return self 비밀글 열람 권한 없음 사유 예외
*/
public static function secretDenied(): self
{
return new self('secret_denied', __('sirsoft-board::messages.reaction.secret_denied'));
}
/**
* 거절 사유를 반환합니다.
*
* @return string 거절 사유 (disabled | inactive_type | self_post)
*/
public function getReason(): string
{
return $this->reason;
}
/**
* 컨트롤러 catch 와 동일한 422 응답으로 렌더링합니다.
*
* @return JsonResponse 반응 불가 응답
*/
public function render(): JsonResponse
{
return ResponseHelper::error($this->getMessage(), 422, ['code' => 'reaction_not_allowed']);
}
}
@@ -288,8 +288,6 @@ class BoardController extends AdminBaseController
// TagInput 입력과 일관되게 배열로 주입 (categories 와 동일 패턴)
$data['blocked_keywords'] = array_values($spamSecurity['blocked_keywords'] ?? []);
$data['allowed_extensions'] = array_values($basicDefaults['allowed_extensions'] ?? []);
// reject(is_array) 로 제거된 배열 필드 — 체크박스 초기 선택 상태 렌더에 필요
$data['active_reaction_types'] = array_values($basicDefaults['active_reaction_types'] ?? []);
// depth 필드는 reject(is_array)로 걸러지지 않지만 basic_defaults가 비어있을 수 있으므로 명시적 기본값 보장
$limits = config('sirsoft-board.limits', []);
@@ -1,50 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Http\Resources\ReactionTypeResource;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
/**
* 관리자용 반응 유형 컨트롤러
*
* 게시판 설정 화면의 "사용할 반응 유형" 체크박스 옵션 소스로 활성 유형 목록을 제공합니다.
* 유형 CRUD 는 이번 범위가 아니며(이슈 #525 확정 01), 목록 조회 전용 API 입니다.
*/
class ReactionTypeController extends AdminBaseController
{
/**
* ReactionTypeController 생성자
*
* @param ReactionTypeRepositoryInterface $reactionTypeRepository 반응 유형 Repository
*/
public function __construct(
private ReactionTypeRepositoryInterface $reactionTypeRepository,
) {
parent::__construct();
}
/**
* 활성 반응 유형 전체를 display_order 순으로 반환합니다.
*
* @return JsonResponse 반응 유형 목록 응답
*/
public function index(): JsonResponse
{
try {
$types = $this->reactionTypeRepository->getActive();
return $this->success(
'sirsoft-board::messages.reaction.list_success',
['reaction_types' => ReactionTypeResource::collection($types)]
);
} catch (\Exception $e) {
Log::error('반응 유형 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error('sirsoft-board::messages.reaction.failed', 500);
}
}
}
@@ -1,77 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Http\Controllers\User;
use App\Http\Controllers\Api\Base\AuthBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Exceptions\ReactionNotAllowedException;
use Modules\Sirsoft\Board\Http\Requests\User\ReactPostRequest;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\ReactionService;
/**
* 사용자용 반응 컨트롤러
*
* 게시글 반응(추천/비추천) 등록·전환·해제를 하나의 엔드포인트로 처리합니다.
*/
class ReactionController extends AuthBaseController
{
/**
* ReactionController 생성자
*
* @param ReactionService $reactionService 반응 서비스
* @param BoardService $boardService 게시판 서비스 (slug → 게시판 해석)
*/
public function __construct(
private ReactionService $reactionService,
private BoardService $boardService,
) {
parent::__construct();
}
/**
* 게시글에 반응합니다 (등록/전환/해제 통합).
*
* @param ReactPostRequest $request 반응 요청 (reaction_type_id 검증)
* @param string $slug 게시판 slug
* @param int $postId 게시글 ID
* @return JsonResponse 반응 결과 응답
*/
public function react(ReactPostRequest $request, string $slug, int $postId): JsonResponse
{
try {
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.error_404');
}
$result = $this->reactionService->react(
(int) Auth::id(),
$board,
$postId,
(int) $request->validated('reaction_type_id'),
);
$messageKey = match ($result['action']) {
'change' => 'sirsoft-board::messages.reaction.change_success',
'remove' => 'sirsoft-board::messages.reaction.remove_success',
default => 'sirsoft-board::messages.reaction.add_success',
};
return $this->success($messageKey, [
'action' => $result['action'],
'my_reaction_type_id' => $result['reaction_type_id'],
'reaction_counts' => $result['reaction_counts'],
]);
} catch (PostNotFoundException $e) {
return $this->notFound('sirsoft-board::messages.post.not_found');
} catch (ReactionNotAllowedException $e) {
return $this->error($e->getMessage(), 422, ['code' => 'reaction_not_allowed']);
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.reaction.failed', 500, $e->getMessage());
}
}
}
@@ -34,8 +34,6 @@ class BulkApplySettingsRequest extends FormRequest
'comment_order',
'show_view_count',
'use_report',
'use_reaction',
'active_reaction_types',
'min_title_length',
'max_title_length',
'min_content_length',
@@ -65,7 +65,6 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.use_reply',
'basic_defaults.show_view_count',
'basic_defaults.use_report',
'basic_defaults.use_reaction',
'basic_defaults.use_file_upload',
'basic_defaults.notify_admin_on_post',
'basic_defaults.notify_author',
@@ -177,9 +176,6 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.comment_order' => ['nullable', 'string', 'in:ASC,DESC'],
'basic_defaults.show_view_count' => ['nullable', 'boolean'],
'basic_defaults.use_report' => ['nullable', 'boolean'],
'basic_defaults.use_reaction' => ['nullable', 'boolean'],
'basic_defaults.active_reaction_types' => ['nullable', 'array'],
'basic_defaults.active_reaction_types.*' => ['string', 'max:50'],
'basic_defaults.min_title_length' => ['nullable', 'integer', "min:{$minTitleLengthMin}", "max:{$minTitleLengthMax}"],
'basic_defaults.max_title_length' => ['nullable', 'integer', "min:{$maxTitleLengthMin}", "max:{$maxTitleLengthMax}"],
'basic_defaults.min_content_length' => ['nullable', 'integer', "min:{$minContentLengthMin}", "max:{$minContentLengthMax}"],
@@ -53,8 +53,6 @@ class StoreBoardRequest extends FormRequest
'secret_mode' => $settings['secret_mode'] ?? 'disabled',
'use_comment' => $settings['use_comment'] ?? true,
'use_reply' => $settings['use_reply'] ?? true,
'use_reaction' => $settings['use_reaction'] ?? true,
'active_reaction_types' => $settings['active_reaction_types'] ?? [],
'max_reply_depth' => $settings['max_reply_depth'] ?? 5,
'max_comment_depth' => $settings['max_comment_depth'] ?? 10,
'use_file_upload' => $settings['use_file_upload'] ?? false,
@@ -88,7 +86,7 @@ class StoreBoardRequest extends FormRequest
// boolean 필드 캐스팅 (Toggle 컴포넌트가 "on"/"off" 문자열을 전송할 수 있음)
$booleanFields = [
'is_active', 'use_comment', 'use_reply', 'use_file_upload',
'use_report', 'use_reaction', 'show_view_count', 'is_notice',
'use_report', 'show_view_count', 'is_notice',
'notify_admin_on_post', 'notify_author',
];
@@ -171,9 +169,6 @@ class StoreBoardRequest extends FormRequest
'use_comment' => ['required', 'boolean'],
'use_reply' => ['required', 'boolean'],
'use_report' => ['required', 'boolean'],
'use_reaction' => ['required', 'boolean'],
'active_reaction_types' => ['sometimes', 'array'],
'active_reaction_types.*' => ['string'],
'comment_order' => ['required', 'in:ASC,DESC'],
'new_display_hours' => ['nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
@@ -75,7 +75,7 @@ class UpdateBoardRequest extends FormRequest
// boolean 필드 캐스팅 (Toggle 컴포넌트가 "on"/"off" 문자열을 전송할 수 있음)
$booleanFields = [
'is_active', 'use_comment', 'use_reply', 'use_file_upload',
'use_report', 'use_reaction', 'show_view_count', 'is_notice',
'use_report', 'show_view_count', 'is_notice',
'notify_admin_on_post', 'notify_author',
];
@@ -164,9 +164,6 @@ class UpdateBoardRequest extends FormRequest
'use_comment' => ['sometimes', 'required', 'boolean'],
'use_reply' => ['sometimes', 'required', 'boolean'],
'use_report' => ['sometimes', 'required', 'boolean'],
'use_reaction' => ['sometimes', 'required', 'boolean'],
'active_reaction_types' => ['sometimes', 'array'],
'active_reaction_types.*' => ['string'],
'comment_order' => ['sometimes', 'required', 'in:ASC,DESC'],
'new_display_hours' => ['sometimes', 'nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
@@ -264,7 +261,6 @@ class UpdateBoardRequest extends FormRequest
$settingFieldKeys = [
'per_page', 'per_page_mobile', 'order_by', 'order_direction',
'secret_mode', 'use_comment', 'use_reply', 'use_report',
'use_reaction', 'active_reaction_types',
'comment_order', 'show_view_count',
'max_reply_depth', 'max_comment_depth',
'min_title_length', 'max_title_length',
@@ -1,68 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Rules\AvailableReactionType;
/**
* 게시글 반응 요청 폼 검증
*
* `reaction_type_id` 가 존재하는 활성 유형이면서 대상 게시판이 켠 유형인지
* 검증합니다. 게시판의 활성 유형 code 목록(`active_reaction_types`)을 조회해
* `AvailableReactionType` Rule 에 전달합니다.
*/
class ReactPostRequest extends FormRequest
{
/**
* 권한 체크는 라우트의 auth:sanctum 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$slug = (string) ($this->route('slug') ?? '');
$board = app(BoardRepositoryInterface::class)->findBySlug($slug);
$activeCodes = $board?->active_reaction_types ?? [];
return [
'reaction_type_id' => ['required', 'integer', new AvailableReactionType($activeCodes)],
];
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'reaction_type_id.required' => __('sirsoft-board::validation.reaction.reaction_type_id.required'),
'reaction_type_id.integer' => __('sirsoft-board::validation.reaction.reaction_type_id.integer'),
];
}
/**
* 검증할 필드의 이름을 커스터마이징
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'reaction_type_id' => __('sirsoft-board::validation.attributes.reaction.reaction_type_id'),
];
}
}
@@ -57,9 +57,6 @@ class BoardResource extends BaseApiResource
'use_reply' => $this->use_reply,
'max_reply_depth' => $this->max_reply_depth,
'use_report' => $this->use_report,
'use_reaction' => $this->use_reaction,
'active_reaction_types' => $this->active_reaction_types ?? [],
'reaction_type_options' => self::buildReactionTypeOptions($this->active_reaction_types ?? []),
'comment_order' => $this->comment_order,
'max_comment_depth' => $this->max_comment_depth,
@@ -269,37 +266,6 @@ class BoardResource extends BaseApiResource
return $this->blocked_keywords ?? [];
}
/**
* 게시판이 켠(활성) 반응 유형의 옵션 배열을 만듭니다.
*
* DB 컬럼 `active_reaction_types`(code 문자열 배열)와 `board_reaction_types` 를
* 조인해 프론트가 바로 쓸 수 있는 `{id, code, name, icon}` 배열로 변환합니다.
* 게시판이 켠 유형 중 시스템에서 살아있는(활성) 유형만, display_order 순으로 노출합니다
* (이슈 #525 확정 11 — 게시판 ∩ 시스템). 응답 필드명을 DB 컬럼과 의도적으로 달리해
* 동명이의어를 방지합니다.
*
* @param array<int, string> $activeCodes 게시판이 켠 반응 유형 code 목록
* @return array<int, array<string, mixed>> 반응 유형 옵션 배열
*/
public static function buildReactionTypeOptions(array $activeCodes): array
{
if (empty($activeCodes)) {
return [];
}
$types = app(\Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface::class)
->findByCodes($activeCodes);
return $types->map(fn ($type) => [
'id' => $type->id,
'code' => $type->code,
'name' => $type->getLocalizedName(),
'icon' => $type->icon,
// Icon 컴포넌트 name prop 용 — 스타일 접두사(fas/far 등)를 떼고 fa-* 토큰만 추출
'icon_name' => $type->getIconName(),
])->values()->all();
}
/**
* 허용 확장자를 배열로 반환합니다.
*
@@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
@@ -78,12 +77,6 @@ class PostResource extends BaseApiResource
// 상세 전용: 신고 여부 (로그인 사용자 + board 관계 로드 시에만)
'is_already_reported' => $this->getIsAlreadyReported($request),
// 상세 전용: 반응 카운트 (활성 유형 전체 항상 포함 — 확정 09, 0도 노출)
'reaction_counts' => $this->getReactionCountsForResponse(),
// 상세 전용: 내가 누른 반응 유형 ID (로그인 사용자 + board 관계 로드 시에만, null 가능)
'my_reaction_type_id' => $this->getMyReactionTypeId($request),
// 권한 정보 (is_owner + abilities) — 상세 페이지에서만 포함
...$this->resourceMeta($request),
];
@@ -348,8 +341,6 @@ class PostResource extends BaseApiResource
'use_comment' => $this->board->use_comment,
'use_reply' => $this->board->use_reply,
'use_report' => $this->board->use_report,
'use_reaction' => $this->board->use_reaction,
'reaction_type_options' => BoardResource::buildReactionTypeOptions($this->board->active_reaction_types ?? []),
'show_view_count' => $this->board->show_view_count,
'max_reply_depth' => $this->board->max_reply_depth ?? g7_module_settings('sirsoft-board', 'basic_defaults.max_reply_depth', 5),
'max_comment_depth' => $this->board->max_comment_depth ?? g7_module_settings('sirsoft-board', 'basic_defaults.max_comment_depth', 10),
@@ -489,58 +480,6 @@ class PostResource extends BaseApiResource
->hasUserReported($user->id, $this->board->id, 'post', $this->id);
}
/**
* 반응 카운트를 응답용으로 반환합니다.
*
* 게시판이 켠(활성) 유형 전체를 키로 항상 포함하며, 저장된 카운트가 없는 유형은
* 0 으로 채웁니다 (확정 09 — 활성 유형은 개수 0이어도 항상 노출). 키는 유형 ID.
*
* 반드시 stdClass(객체)로 반환한다 — 키가 정수(유형 ID)뿐이면 배열이 list 로 오인되어
* JsonResource::resolve() 가 `[count1, count2]` 로 재인덱싱하고, 그러면 프론트가
* `reaction_counts[유형ID]` 로 읽을 때 엉뚱한 인덱스를 읽어 개수가 항상 0/어긋난다.
* stdClass 는 array 필터의 재인덱싱을 타지 않아 항상 `{"유형ID": 개수}` JSON 객체로 나간다.
*
* @return \stdClass 유형 ID => 개수 (JSON 객체)
*/
private function getReactionCountsForResponse(): \stdClass
{
if (! $this->relationLoaded('board') || ! $this->board) {
return new \stdClass;
}
$stored = $this->reaction_counts ?? [];
$options = BoardResource::buildReactionTypeOptions($this->board->active_reaction_types ?? []);
$counts = [];
foreach ($options as $option) {
$key = (string) $option['id'];
$counts[$key] = (int) ($stored[$key] ?? 0);
}
return (object) $counts;
}
/**
* 로그인 사용자가 이 게시글에 남긴 반응 유형 ID 를 반환합니다.
*
* 로그인 사용자 + board 관계 로드 시에만 조회하며, 반응이 없으면 null 을 반환합니다.
*
* @param Request $request HTTP 요청
* @return int|null 내가 누른 반응 유형 ID (없으면 null)
*/
private function getMyReactionTypeId(Request $request): ?int
{
$user = $request->user();
if (! $user || ! $this->relationLoaded('board') || ! $this->board) {
return null;
}
$reaction = app(ReactionRepositoryInterface::class)
->findByUserAndTarget($user->id, 'post', $this->id);
return $reaction?->reaction_type_id;
}
// =========================================================================
// 상세 페이지 전용: 조건부 필드 메서드
// =========================================================================
@@ -1,30 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
/**
* 반응 유형 API 리소스
*
* 반응 유형을 API 응답 형식으로 변환합니다. name 은 현재 로케일 문자열로
* 내려주며, 미입력 언어는 getLocalizedName() 이 폴백 처리합니다 (이슈 #525 확정 15).
*/
class ReactionTypeResource extends BaseApiResource
{
/**
* @param Request $request HTTP 요청
* @return array<string, mixed> 변환된 배열 데이터
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->getLocalizedName(),
'icon' => $this->icon,
'icon_name' => $this->getIconName(),
];
}
}
@@ -12,7 +12,6 @@ use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
/**
@@ -30,11 +29,9 @@ class BoardActivityLogListener implements HookListenerInterface
/**
* @param ReportRepositoryInterface $reportRepository 신고 bulk lookup
* @param ReactionTypeRepositoryInterface $reactionTypeRepository 반응 유형 라벨 조회
*/
public function __construct(
protected ReportRepositoryInterface $reportRepository,
protected ReactionTypeRepositoryInterface $reactionTypeRepository,
) {}
/**
@@ -86,9 +83,6 @@ class BoardActivityLogListener implements HookListenerInterface
'sirsoft-board.report.after_restore_content' => ['method' => 'handleReportAfterRestoreContent', 'priority' => 20],
'sirsoft-board.report.after_blind_content' => ['method' => 'handleReportAfterBlindContent', 'priority' => 20],
'sirsoft-board.report.after_delete_content' => ['method' => 'handleReportAfterDeleteContent', 'priority' => 20],
// ─── Reaction ───
'sirsoft-board.reaction.after_react' => ['method' => 'handleReactionAfterReact', 'priority' => 20],
];
}
@@ -722,51 +716,4 @@ class BoardActivityLogListener implements HookListenerInterface
]);
}
// ═══════════════════════════════════════════
// Reaction 핸들러
// ═══════════════════════════════════════════
/**
* 반응 등록/전환/해제 후 로그 기록
*
* @param int $userId 반응한 사용자 ID
* @param Post $post 대상 게시글
* @param int $reactionTypeId 반응 유형 ID
* @param string $action 수행된 동작 (add | change | remove)
*/
public function handleReactionAfterReact(int $userId, Post $post, int $reactionTypeId, string $action): void
{
$post->loadMissing('board');
$reactionType = $this->reactionTypeRepository->findById($reactionTypeId);
$typeName = $reactionType?->getLocalizedName() ?? '';
$actionKey = match ($action) {
'change' => 'reaction.change',
'remove' => 'reaction.remove',
default => 'reaction.add',
};
$descriptionKey = match ($action) {
'change' => 'sirsoft-board::activity_log.description.reaction_change',
'remove' => 'sirsoft-board::activity_log.description.reaction_remove',
default => 'sirsoft-board::activity_log.description.reaction_add',
};
$this->logActivity($actionKey, [
'loggable' => $post,
'description_key' => $descriptionKey,
'description_params' => [
'title' => $post->title ?? '',
'reaction_type' => $typeName,
'board_name' => $post->board?->name ?? '',
],
'properties' => [
'title' => $post->title,
'reaction_type_id' => $reactionTypeId,
'reaction_type' => $typeName,
'board_name' => $post->board?->name ?? '',
],
]);
}
}
@@ -127,8 +127,6 @@ class Board extends Model
'use_reply',
'max_reply_depth',
'use_report',
'use_reaction',
'active_reaction_types',
'new_display_hours',
// 입력 제한 설정
@@ -185,9 +183,6 @@ class Board extends Model
// Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다.
'new_display_hours' => 'integer',
'use_report' => 'boolean',
'use_reaction' => 'boolean',
// 활성 반응 유형 code 목록 (문자열 배열, 예: ["like","dislike"])
'active_reaction_types' => 'array',
'use_file_upload' => 'boolean',
'notify_author' => 'boolean',
'notify_admin_on_post' => 'boolean',
@@ -79,7 +79,6 @@ class Post extends Model implements FulltextSearchable
'replies_count',
'comments_count',
'attachments_count',
'reaction_counts',
];
/**
@@ -100,8 +99,6 @@ class Post extends Model implements FulltextSearchable
'replies_count' => 'integer',
'comments_count' => 'integer',
'attachments_count' => 'integer',
// 반응 유형별 개수 (키는 유형 ID 문자열, 예: {"1":18,"2":2})
'reaction_counts' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
@@ -1,89 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 게시판 반응 이력 모델.
*
* 사용자당 대상(게시글)에 1행. 등록=INSERT, 전환=UPDATE, 해제=DELETE
* (이슈 #525 확정 03, 05). target_type/target_id 폴리모픽 구조 (확정 10).
*
* @property int $id
* @property int $user_id
* @property string $target_type
* @property int $target_id
* @property int $reaction_type_id
* @property int|null $board_id
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
*/
class Reaction extends Model
{
protected $table = 'board_reactions';
protected $fillable = [
'user_id',
'target_type',
'target_id',
'reaction_type_id',
'board_id',
];
protected function casts(): array
{
return [
'user_id' => 'integer',
'target_id' => 'integer',
'reaction_type_id' => 'integer',
'board_id' => 'integer',
];
}
/**
* 반응한 사용자와의 관계.
*
* @return BelongsTo<User, Reaction>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* 반응 유형과의 관계.
*
* @return BelongsTo<ReactionType, Reaction>
*/
public function reactionType(): BelongsTo
{
return $this->belongsTo(ReactionType::class, 'reaction_type_id');
}
/**
* 게시판과의 관계 (nullable).
*
* @return BelongsTo<Board, Reaction>
*/
public function board(): BelongsTo
{
return $this->belongsTo(Board::class, 'board_id');
}
/**
* 특정 대상(타입+ID)의 반응만 조회하는 스코프.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $targetType 대상 타입 (예: post)
* @param int $targetId 대상 ID
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByTarget($query, string $targetType, int $targetId)
{
return $query->where('target_type', $targetType)
->where('target_id', $targetId);
}
}
@@ -1,116 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Models;
use App\Casts\AsUnicodeJson;
use App\Models\Concerns\HasUserOverrides;
use Illuminate\Database\Eloquent\Model;
/**
* 게시판 반응 유형 모델.
*
* 반응(추천/비추천 등) 유형을 DB로 관리한다. name은 다국어 JSON,
* 조회 시 getLocalizedName()이 로케일 폴백을 처리한다 (이슈 #525 확정 15).
*
* @property int $id
* @property string $code
* @property array $name
* @property string|null $icon
* @property int $display_order
* @property bool $is_active
* @property array|null $user_overrides
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
*/
class ReactionType extends Model
{
use HasUserOverrides;
protected $table = 'board_reaction_types';
protected $fillable = [
'code',
'name',
'icon',
'display_order',
'is_active',
'user_overrides',
];
/**
* 사용자 수정 보존 대상 필드.
*
* @var array<int, string>
*/
protected array $trackableFields = ['name', 'icon'];
/**
* 다국어 JSON 컬럼 — sub-key dot-path 단위 user_overrides 보존.
*
* @var array<int, string>
*/
protected array $translatableTrackableFields = ['name'];
protected function casts(): array
{
return [
'name' => AsUnicodeJson::class,
'display_order' => 'integer',
'is_active' => 'boolean',
'user_overrides' => 'array',
];
}
/**
* 지정된 로케일의 반응 유형명 반환 (미입력 언어는 폴백).
*
* @param string|null $locale 로케일 (null이면 현재 로케일)
* @return string 해당 로케일의 유형명
*/
public function getLocalizedName(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
if (! is_array($this->name)) {
return (string) $this->name;
}
return $this->name[$locale]
?? $this->name[config('app.fallback_locale')]
?? (! empty($this->name) ? array_values($this->name)[0] : '')
?? '';
}
/**
* 활성 유형만 조회하는 스코프.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* 저장된 Font Awesome 클래스(`fas fa-thumbs-up`)에서 Icon 컴포넌트 name prop 용
* 아이콘 토큰(`fa-thumbs-up`)만 추출합니다. Icon 컴포넌트가 스타일 접두사를 자체 부착하므로
* 원본 클래스를 그대로 name 에 넘기면 접두사가 중복됩니다.
*
* @return string|null Icon name prop 값 (없으면 null)
*/
public function getIconName(): ?string
{
if ($this->icon === null || $this->icon === '') {
return null;
}
foreach (preg_split('/\s+/', trim($this->icon)) as $token) {
if (str_starts_with($token, 'fa-')) {
return $token;
}
}
return $this->icon;
}
}
@@ -16,13 +16,9 @@ use Modules\Sirsoft\Board\Repositories\Contracts\BoardStatRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardTypeRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\UserNotificationSettingRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\PostRepository;
use Modules\Sirsoft\Board\Repositories\ReactionRepository;
use Modules\Sirsoft\Board\Repositories\ReactionTypeRepository;
use Modules\Sirsoft\Board\Repositories\ReportRepository;
use Modules\Sirsoft\Board\Repositories\UserNotificationSettingRepository;
use Modules\Sirsoft\Board\Seo\BoardSitemapContributor;
@@ -81,8 +77,6 @@ class BoardServiceProvider extends BaseModuleServiceProvider
BoardTypeRepositoryInterface::class => BoardTypeRepository::class,
CommentRepositoryInterface::class => CommentRepository::class,
PostRepositoryInterface::class => PostRepository::class,
ReactionRepositoryInterface::class => ReactionRepository::class,
ReactionTypeRepositoryInterface::class => ReactionTypeRepository::class,
ReportRepositoryInterface::class => ReportRepository::class,
UserNotificationSettingRepositoryInterface::class => UserNotificationSettingRepository::class,
];
@@ -1,67 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Repositories\Contracts;
use Modules\Sirsoft\Board\Models\Reaction;
/**
* 반응 이력 Repository 인터페이스
*/
interface ReactionRepositoryInterface
{
/**
* 사용자+대상 기준 기존 반응을 조회합니다 (유일 제약과 동일 키).
*
* @param int $userId 사용자 ID
* @param string $targetType 대상 타입 (예: post)
* @param int $targetId 대상 ID
* @return Reaction|null 기존 반응 또는 null
*/
public function findByUserAndTarget(int $userId, string $targetType, int $targetId): ?Reaction;
/**
* 대상 게시글 행을 `lockForUpdate` 로 잠급니다.
*
* 짧은 시간에 연속 전송된 반응 요청이 서버에 도착 순서와 다르게 처리되어도
* (요청 A→B→C 전송, B→A→C 처리 등) `existing` 조회부터 upsert/delete·카운트
* 재집계까지 동일 게시글에 대한 처리 전체가 이 잠금 하나로 직렬화되도록,
* 반응 처리 트랜잭션 진입 직후 가장 먼저 호출해야 합니다.
*
* @param int $postId 게시글 ID
*/
public function lockPostForReaction(int $postId): void;
/**
* 반응을 등록하거나 전환합니다 (없으면 INSERT, 있으면 유형 UPDATE).
*
* @param int $userId 사용자 ID
* @param string $targetType 대상 타입
* @param int $targetId 대상 ID
* @param int $reactionTypeId 반응 유형 ID
* @param int|null $boardId 게시판 ID
* @return Reaction 등록/전환된 반응 모델
*/
public function upsert(int $userId, string $targetType, int $targetId, int $reactionTypeId, ?int $boardId): Reaction;
/**
* 반응을 해제합니다 (이력 행 삭제).
*
* @param Reaction $reaction 삭제할 반응 모델
* @return bool 삭제 성공 여부
*/
public function delete(Reaction $reaction): bool;
/**
* 게시글의 반응 카운트(JSON)를 board_reactions 실제 행 기준으로 재집계합니다.
*
* 반드시 트랜잭션 안에서 호출되어야 하며, 대상 게시글 행을 `lockForUpdate` 로 잠근 뒤
* 그 잠금 범위 안에서 COUNT 재집계까지 수행해 동시 요청의 처리 순서가 뒤바뀌어도
* (짧은 시간에 연속 전송된 반응 요청이 도착 순서와 다르게 처리되는 경우) 캐시된
* 카운트가 실제 반응 행 수와 항상 일치하도록 보장합니다. 델타 누적 방식은 요청
* 순서 역전 시 캐시가 실제 데이터와 어긋날 수 있어 사용하지 않습니다.
*
* @param int $postId 게시글 ID
* @return array<string, int> 갱신 후 reaction_counts (키는 유형 ID 문자열, 값은 실제 COUNT)
*/
public function recalculatePostReactionCounts(int $postId): array;
}
@@ -1,43 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Modules\Sirsoft\Board\Models\ReactionType;
/**
* 반응 유형 Repository 인터페이스
*/
interface ReactionTypeRepositoryInterface
{
/**
* 활성 반응 유형 전체를 display_order 순으로 조회합니다.
*
* @return Collection<int, ReactionType>
*/
public function getActive(): Collection;
/**
* code 목록으로 활성 반응 유형을 조회합니다 (display_order 순).
*
* @param array<int, string> $codes 유형 code 목록
* @return Collection<int, ReactionType>
*/
public function findByCodes(array $codes): Collection;
/**
* ID로 반응 유형을 조회합니다.
*
* @param int $id 유형 ID
* @return ReactionType|null 유형 모델 또는 null
*/
public function findById(int $id): ?ReactionType;
/**
* ID 목록으로 반응 유형을 한 번에 조회합니다 (N+1 방지).
*
* @param array<int, int> $ids 유형 ID 목록
* @return Collection<int, ReactionType>
*/
public function findByIds(array $ids): Collection;
}
@@ -1,92 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Repositories;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Models\Reaction;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
/**
* 반응 이력 Repository
*
* 반응 이력 데이터 접근 계층을 담당합니다.
*/
class ReactionRepository implements ReactionRepositoryInterface
{
/**
* {@inheritDoc}
*/
public function findByUserAndTarget(int $userId, string $targetType, int $targetId): ?Reaction
{
return Reaction::query()
->where('user_id', $userId)
->byTarget($targetType, $targetId)
->first();
}
/**
* {@inheritDoc}
*/
public function lockPostForReaction(int $postId): void
{
Post::query()->lockForUpdate()->findOrFail($postId);
}
/**
* {@inheritDoc}
*/
public function upsert(int $userId, string $targetType, int $targetId, int $reactionTypeId, ?int $boardId): Reaction
{
return Reaction::updateOrCreate(
[
'user_id' => $userId,
'target_type' => $targetType,
'target_id' => $targetId,
],
[
'reaction_type_id' => $reactionTypeId,
'board_id' => $boardId,
],
);
}
/**
* {@inheritDoc}
*/
public function delete(Reaction $reaction): bool
{
return (bool) $reaction->delete();
}
/**
* {@inheritDoc}
*/
public function recalculatePostReactionCounts(int $postId): array
{
/** @var Post $post */
$post = Post::query()->lockForUpdate()->findOrFail($postId);
// 이전에 캐시에 등장했던 유형은 반응이 0건이 되어도 키 자체는 유지한다
// (API 응답 계약 — 한 번 노출된 유형의 카운트는 0으로라도 항상 존재).
$zeroed = array_fill_keys(array_keys($post->reaction_counts ?? []), 0);
$actual = Reaction::query()
->byTarget('post', $postId)
->selectRaw('reaction_type_id, COUNT(*) as total')
->groupBy('reaction_type_id')
->pluck('total', 'reaction_type_id')
->mapWithKeys(fn ($total, $typeId) => [(string) $typeId => (int) $total])
->toArray();
// array_merge 는 숫자형 키(반응 유형 ID)를 재색인해 순서/키를 잃는다
// (예: [1=>1] 이 [0=>1] 로 바뀌어 JSON 직렬화 시 객체 대신 리스트가 됨).
// + 연산자는 키를 그대로 보존하면서 좌측을 우선하므로 실제 COUNT($actual)로
// zeroed 를 덮어쓰려면 우측에 두어야 한다.
$counts = $actual + $zeroed;
$post->reaction_counts = $counts;
$post->save();
return $counts;
}
}
@@ -1,65 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
/**
* 반응 유형 Repository
*
* 반응 유형 데이터 접근 계층을 담당합니다.
*/
class ReactionTypeRepository implements ReactionTypeRepositoryInterface
{
/**
* {@inheritDoc}
*/
public function getActive(): Collection
{
return ReactionType::query()
->where('is_active', true)
->orderBy('display_order')
->get();
}
/**
* {@inheritDoc}
*/
public function findByCodes(array $codes): Collection
{
if (empty($codes)) {
return ReactionType::query()->whereRaw('1 = 0')->get();
}
return ReactionType::query()
->where('is_active', true)
->whereIn('code', $codes)
->orderBy('display_order')
->get();
}
/**
* {@inheritDoc}
*/
public function findById(int $id): ?ReactionType
{
return ReactionType::query()->find($id);
}
/**
* {@inheritDoc}
*/
public function findByIds(array $ids): Collection
{
if (empty($ids)) {
return ReactionType::query()->whereIn('id', [])->get();
}
return ReactionType::query()
->whereIn('id', $ids)
->orderBy('display_order')
->get();
}
}
@@ -1,49 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
/**
* 반응 유형 검증 규칙
*
* `reaction_type_id` 가 존재하는 활성 유형이면서 대상 게시판이 켠(활성) 유형인지
* 판정합니다. 게시판이 켠 유형 목록은 DB(`boards.active_reaction_types`, code 배열)에
* 저장되므로 허용 목록을 요청 클래스에 하드코딩하지 않습니다
* (`AvailableNotificationChannel` 과 동일한 서비스 기반 검증 구조).
*/
class AvailableReactionType implements ValidationRule
{
/**
* @param array<int, string> $activeCodes 대상 게시판이 켠 반응 유형 code 목록
*/
public function __construct(
private array $activeCodes = []
) {}
/**
* 검증 규칙 실행
*
* @param string $attribute 필드명
* @param mixed $value 검증 값 (reaction_type_id)
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_numeric($value)) {
$fail(__('sirsoft-board::messages.reaction.inactive_type'));
return;
}
$type = app(ReactionTypeRepositoryInterface::class)->findById((int) $value);
if ($type === null
|| ! $type->is_active
|| ! in_array($type->code, $this->activeCodes, true)) {
$fail(__('sirsoft-board::messages.reaction.inactive_type'));
}
}
}
@@ -1,190 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Services;
use App\Enums\PermissionType;
use App\Extension\HookManager;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Exceptions\ReactionNotAllowedException;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
* 반응 서비스
*
* 게시글 반응의 등록/전환/해제를 단일 진입점(`react()`)으로 처리합니다.
* 확정 03(글당 1개, 전환 시 대체), 07(로그인 필수 — 컨트롤러 미들웨어),
* 08(본인 글 차단), 11(비활성 유형 차단)을 강제합니다.
*/
class ReactionService
{
use ChecksBoardPermission;
/**
* 현재 지원하는 반응 대상 타입 (확정 10 — 게시글만, 향후 comment 확장 가능).
*/
private const TARGET_POST = 'post';
/**
* @param ReactionRepositoryInterface $reactionRepository 반응 이력 Repository
* @param ReactionTypeRepositoryInterface $reactionTypeRepository 반응 유형 Repository
* @param PostRepositoryInterface $postRepository 게시글 스코프 조회 Repository
*/
public function __construct(
private readonly ReactionRepositoryInterface $reactionRepository,
private readonly ReactionTypeRepositoryInterface $reactionTypeRepository,
private readonly PostRepositoryInterface $postRepository,
) {}
/**
* 게시글에 반응을 남깁니다 (등록/전환/해제 통합).
*
* 게시글이 대상 게시판 소속인지 스코프 검증을 먼저 수행합니다 (교차 접근 차단).
*
* - 기존 반응 없음 → 신규(INSERT), 해당 유형 +1
* - 기존 반응이 같은 유형 → 해제(DELETE), 해당 유형 -1
* - 기존 반응이 다른 유형 → 전환(UPDATE), 이전 유형 -1 · 신규 유형 +1
*
* 카운트 증감과 이력 쓰기는 단일 트랜잭션으로 원자 처리합니다 (확정 04·동시성).
*
* @param int $userId 반응한 사용자 ID
* @param Board $board 대상 게시판 (컨트롤러가 slug 로 해석 후 전달)
* @param int $postId 대상 게시글 ID
* @param int $reactionTypeId 반응 유형 ID
* @return array{action: string, reaction_type_id: int|null, reaction_counts: array<string, int>}
*
* @throws PostNotFoundException 게시글이 이 게시판 소속이 아니거나 미존재
* @throws ReactionNotAllowedException 반응 비활성 게시판 / 비활성 유형 / 본인 글
*/
public function react(int $userId, Board $board, int $postId, int $reactionTypeId): array
{
// 게시글이 이 게시판 소속인지 스코프 검증 (교차 접근 차단)
$post = $this->postRepository->findByBoardId($board->id, $postId);
if ($post === null) {
throw new PostNotFoundException($postId);
}
// 반응 기능 off (확정 07 전제)
if (! $board->use_reaction) {
throw ReactionNotAllowedException::disabled();
}
// 본인 글 반응 차단 (확정 08)
if ((int) $post->user_id === $userId) {
throw ReactionNotAllowedException::selfPost();
}
// 비밀글 반응 차단 — 본문 열람 권한이 없는 사용자는 반응 불가.
// 신고(PostResource::canViewSecretContent)와 동일한 판정을 재사용해,
// 본문을 못 보는 사용자가 반응만 남기는 우회를 막는다.
// 작성자 본인은 위에서 이미 차단되므로 여기서는 게시판별 비밀글 열람 권한만 본다.
if ($post->is_secret && ! $this->canReactToSecretPost($board->slug)) {
throw ReactionNotAllowedException::secretDenied();
}
// 요청 유형이 존재하는 활성 유형이면서 게시판이 켠 유형인지 확인 (확정 11)
$requestedType = $this->reactionTypeRepository->findById($reactionTypeId);
$activeCodes = $board->active_reaction_types ?? [];
if ($requestedType === null
|| ! $requestedType->is_active
|| ! in_array($requestedType->code, $activeCodes, true)) {
throw ReactionNotAllowedException::inactiveType();
}
HookManager::doAction('sirsoft-board.reaction.before_react', $userId, $post, $reactionTypeId);
$result = DB::transaction(function () use ($userId, $board, $post, $reactionTypeId) {
// 이 게시글에 대한 반응 처리를 직렬화 — 짧은 시간에 연속 전송된 요청이
// 서버 도착 순서와 다르게 처리되어도(A→B→C 전송, B→A→C 처리 등) existing
// 조회부터 upsert/delete·카운트 재집계까지가 이 잠금 하나로 묶여, 이후
// 로직이 항상 "이 트랜잭션 차례가 됐을 때의 실제 최신 상태"를 기준으로
// 판단하도록 보장한다. 잠금 없이 existing 을 먼저 읽으면 다른 트랜잭션이
// 그 사이 상태를 바꿔도 반영되지 않아, 이미 지워진 반응을 다시 지우거나
// 캐시 카운트가 실제 반응 행 수와 어긋나는 결함으로 이어진다.
//
// 스코프 검증 통과 직후·락 획득 시점 사이에 게시글이 삭제되는 레이스에서는
// findOrFail() 이 ModelNotFoundException 을 던진다. 컨트롤러는 이 예외를
// 모르므로 그대로 두면 일반 500 으로 새어나가 사용자가 원인을 알 수 없다 —
// 이미 위에서 존재를 확인한 게시글이 사라진 것과 같은 의미이므로 동일하게
// PostNotFoundException 으로 변환한다.
try {
$this->reactionRepository->lockPostForReaction($post->id);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
throw new PostNotFoundException($post->id);
}
$existing = $this->reactionRepository->findByUserAndTarget(
$userId,
self::TARGET_POST,
$post->id,
);
// 같은 유형 재요청 → 해제
if ($existing !== null && (int) $existing->reaction_type_id === $reactionTypeId) {
$this->reactionRepository->delete($existing);
$counts = $this->reactionRepository->recalculatePostReactionCounts($post->id);
return ['action' => 'remove', 'reaction_type_id' => null, 'reaction_counts' => $counts];
}
// 다른 유형 → 전환 (이전 -1, 신규 +1)
if ($existing !== null) {
$this->reactionRepository->upsert(
$userId,
self::TARGET_POST,
$post->id,
$reactionTypeId,
$board->id,
);
$counts = $this->reactionRepository->recalculatePostReactionCounts($post->id);
return ['action' => 'change', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts];
}
// 신규 등록
$this->reactionRepository->upsert(
$userId,
self::TARGET_POST,
$post->id,
$reactionTypeId,
$board->id,
);
$counts = $this->reactionRepository->recalculatePostReactionCounts($post->id);
return ['action' => 'add', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts];
});
HookManager::doAction(
'sirsoft-board.reaction.after_react',
$userId,
$post,
$reactionTypeId,
$result['action'],
);
return $result;
}
/**
* 비밀글에 반응할 수 있는 열람 권한이 있는지 확인합니다.
*
* 반응은 사용자(User) 페이지 전용 기능이므로 게시판별 비밀글 읽기 권한
* (posts.read-secret) 또는 게시판 매니저 권한만 인정합니다. 작성자 본인은
* 호출 이전에 이미 selfPost 로 차단되므로 여기서는 고려하지 않습니다.
* `PostResource::canViewSecretContent` 의 게시판 권한 판정과 동일한 기준입니다.
*
* @param string $slug 대상 게시판 슬러그
* @return bool 비밀글 반응 가능 여부
*/
private function canReactToSecretPost(string $slug): bool
{
return $this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
}
}
@@ -4,10 +4,8 @@ return [
// Action labels (last segment).
// ActivityLog::getActionLabelAttribute resolves module-origin labels from the module's lang first.
'action' => [
'add' => 'Reaction Added',
'add_to_menu' => 'Added to Menu',
'blind' => 'Blinded',
'change' => 'Reaction Changed',
'blind_content' => 'Content Blinded',
'bulk_apply' => 'Bulk Applied',
'bulk_apply_aborted' => 'Bulk Apply Aborted',
@@ -16,7 +14,6 @@ return [
'delete' => 'Deleted',
'delete_content' => 'Content Deleted',
'download' => 'Downloaded',
'remove' => 'Reaction Removed',
'remove_from_menu' => 'Removed from Menu',
'restore' => 'Restored',
'restore_content' => 'Content Restored',
@@ -72,11 +69,6 @@ return [
'report_blind_content' => 'Report content blinded (ID: :report_id)',
'report_delete_content' => 'Report content deleted (ID: :report_id)',
// Reaction (recommend/not recommend)
'reaction_add' => 'Reaction added (Board: :board_name, Title: :title, Type: :reaction_type)',
'reaction_change' => 'Reaction changed (Board: :board_name, Title: :title, Type: :reaction_type)',
'reaction_remove' => 'Reaction removed (Board: :board_name, Title: :title)',
// Board settings
'board_settings_index' => 'Board settings viewed',
'board_settings_bulk_apply' => 'Board settings bulk applied',
@@ -124,20 +124,6 @@ return [
'post_deleted' => 'You cannot comment on a deleted post.',
],
// Reaction (recommend/not recommend) messages
'reaction' => [
'list_success' => 'Reaction types have been retrieved.',
'add_success' => 'Your reaction has been added.',
'change_success' => 'Your reaction has been changed.',
'remove_success' => 'Your reaction has been removed.',
'failed' => 'Failed to process the reaction.',
'disabled' => 'Reactions are disabled for this board.',
'inactive_type' => 'This reaction type is not available on this board.',
'self_post' => 'You cannot react to your own post.',
'secret_denied' => 'You cannot react to a secret post you are not allowed to view.',
'login_required' => 'You need to sign in to use this feature.',
],
// Additional comment messages
'comments' => [
'comments_disabled' => 'Comments are disabled for this board.',
@@ -307,8 +307,6 @@ return [
'basic_defaults.comment_order' => 'Comment Order',
'basic_defaults.show_view_count' => 'Show View Count',
'basic_defaults.use_report' => 'Use Report',
'basic_defaults.use_reaction' => 'Use Reaction',
'basic_defaults.active_reaction_types' => 'Active Reaction Types',
'basic_defaults.min_title_length' => 'Min Title Length',
'basic_defaults.max_title_length' => 'Max Title Length',
'basic_defaults.min_content_length' => 'Min Content Length',
@@ -371,9 +369,6 @@ return [
'process_note' => 'Process Note',
'ids' => 'Report IDs',
],
'reaction' => [
'reaction_type_id' => 'Reaction Type',
],
'blind' => [
'reason' => 'Blind Reason',
],
@@ -585,12 +580,6 @@ return [
],
// Report validation messages
'reaction' => [
'reaction_type_id' => [
'required' => 'The reaction type is required.',
'integer' => 'The reaction type must be an integer.',
],
],
'report' => [
'invalid_status_transition' => 'The report cannot be changed to that status from its current status.',
'status' => [
@@ -4,10 +4,8 @@ return [
// 액션 라벨 (마지막 세그먼트 기준).
// ActivityLog::getActionLabelAttribute 가 모듈 origin 라벨을 자체 lang 에서 우선 조회.
'action' => [
'add' => '반응 등록',
'add_to_menu' => '메뉴 추가',
'blind' => '블라인드',
'change' => '반응 변경',
'blind_content' => '콘텐츠 블라인드',
'bulk_apply' => '일괄 적용',
'bulk_apply_aborted' => '일괄 적용 중단',
@@ -16,7 +14,6 @@ return [
'delete' => '삭제',
'delete_content' => '콘텐츠 삭제',
'download' => '다운로드',
'remove' => '반응 취소',
'remove_from_menu' => '메뉴 제거',
'restore' => '복원',
'restore_content' => '콘텐츠 복원',
@@ -72,11 +69,6 @@ return [
'report_blind_content' => '신고 콘텐츠 블라인드 (ID: :report_id)',
'report_delete_content' => '신고 콘텐츠 삭제 (ID: :report_id)',
// 반응 (추천/비추천)
'reaction_add' => '반응 등록 (게시판: :board_name, 제목: :title, 유형: :reaction_type)',
'reaction_change' => '반응 변경 (게시판: :board_name, 제목: :title, 유형: :reaction_type)',
'reaction_remove' => '반응 취소 (게시판: :board_name, 제목: :title)',
// 게시판 설정
'board_settings_index' => '게시판 설정 조회',
'board_settings_bulk_apply' => '게시판 설정 일괄 적용',
@@ -124,20 +124,6 @@ return [
'post_deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.',
],
// 반응(추천/비추천) 관련 메시지
'reaction' => [
'list_success' => '반응 유형 목록을 조회했습니다.',
'add_success' => '반응을 남겼습니다.',
'change_success' => '반응을 변경했습니다.',
'remove_success' => '반응을 취소했습니다.',
'failed' => '반응 처리에 실패했습니다.',
'disabled' => '이 게시판은 반응 기능이 비활성화되어 있습니다.',
'inactive_type' => '이 게시판에서 사용할 수 없는 반응 유형입니다.',
'self_post' => '본인 글에는 반응할 수 없습니다.',
'secret_denied' => '열람 권한이 없는 비밀글에는 반응할 수 없습니다.',
'login_required' => '로그인이 필요한 기능입니다.',
],
// 댓글 관련 추가 메시지
'comments' => [
'comments_disabled' => '이 게시판은 댓글 기능이 비활성화되어 있습니다.',
@@ -307,8 +307,6 @@ return [
'basic_defaults.comment_order' => '댓글 정렬',
'basic_defaults.show_view_count' => '조회수 표시',
'basic_defaults.use_report' => '신고 기능 사용',
'basic_defaults.use_reaction' => '반응 기능 사용',
'basic_defaults.active_reaction_types' => '사용할 반응 유형',
'basic_defaults.min_title_length' => '최소 제목 길이',
'basic_defaults.max_title_length' => '최대 제목 길이',
'basic_defaults.min_content_length' => '최소 내용 길이',
@@ -371,9 +369,6 @@ return [
'process_note' => '처리 메모',
'ids' => '신고 ID',
],
'reaction' => [
'reaction_type_id' => '반응 유형',
],
'blind' => [
'reason' => '블라인드 사유',
],
@@ -585,12 +580,6 @@ return [
],
// 신고 검증 메시지
'reaction' => [
'reaction_type_id' => [
'required' => '반응 유형은 필수입니다.',
'integer' => '반응 유형은 정수여야 합니다.',
],
],
'report' => [
'invalid_status_transition' => '현재 신고 상태에서는 해당 상태로 변경할 수 없습니다.',
'status' => [
@@ -8,13 +8,11 @@ use Modules\Sirsoft\Board\Http\Controllers\Admin\BoardTypeController;
use Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController as AdminCommentController;
use Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController;
use Modules\Sirsoft\Board\Http\Controllers\Admin\PostController as AdminPostController;
use Modules\Sirsoft\Board\Http\Controllers\Admin\ReactionTypeController as AdminReactionTypeController;
use Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController as AdminReportController;
use Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController as UserAttachmentController;
use Modules\Sirsoft\Board\Http\Controllers\User\BoardController as UserBoardController;
use Modules\Sirsoft\Board\Http\Controllers\User\CommentController as UserCommentController;
use Modules\Sirsoft\Board\Http\Controllers\User\PostController as UserPostController;
use Modules\Sirsoft\Board\Http\Controllers\User\ReactionController as UserReactionController;
use Modules\Sirsoft\Board\Http\Controllers\User\ReportController as UserReportController;
use Modules\Sirsoft\Board\Http\Controllers\User\UserActivityController;
@@ -128,11 +126,6 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function ()
->middleware('permission:admin,sirsoft-board.settings.read')
->name('admin.settings.show');
// 반응 유형 목록 (게시판 설정 체크박스 옵션 소스, 확정 02·03)
Route::get('reaction-types', [AdminReactionTypeController::class, 'index'])
->middleware('permission:admin,sirsoft-board.settings.read')
->name('admin.reaction-types.index');
// 대시보드 - 오늘 새 글/댓글 현황 (진입 가드는 코어 core.dashboard.read + admin)
Route::get('dashboard/overview', [DashboardController::class, 'overview'])
->name('admin.dashboard.overview');
@@ -547,10 +540,6 @@ Route::prefix('boards/{slug}')->middleware(['throttle:600,1', 'auth:sanctum'])->
// 댓글 신고
Route::post('/comments/{commentId}/reports', [UserReportController::class, 'storeCommentReport'])
->name('comments.reports.store');
// 게시글 반응 (추천/비추천) — 등록/전환/해제 통합 (회원 전용, 확정 07)
Route::post('/posts/{postId}/react', [UserReactionController::class, 'react'])
->name('posts.react');
});
/*
@@ -146,37 +146,6 @@ class BoardManagementTest extends ModuleTestCase
$this->assertTrue($response->json('data.is_active'));
}
/**
* 게시판 생성 시 반응 기본값이 모듈 환경설정(basic_defaults)에서 시드된다 (이슈 #525 확정 13).
*
* @scenario case=new_board_seeds_reaction_defaults
* @effects new_board_seeds_reaction_defaults_from_settings
*/
public function test_board_created_seeds_reaction_defaults_from_settings(): void
{
// Given: 반응 관련 필드를 지정하지 않은 게시판 데이터 (기본값 시드 기대)
$slug = 'test-'.substr(md5(microtime()), 0, 8);
$data = [
'name' => ['ko' => '테스트 게시판', 'en' => 'Test Board'],
'slug' => $slug,
'type' => 'basic',
'show_view_count' => true,
'use_report' => false,
'board_manager_ids' => [$this->adminUser->uuid],
];
// When: 게시판 생성 API 호출
$response = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-board/admin/boards', $data);
// Then: 반응 기본값(use_reaction=true, active_reaction_types=["like"])이 시드됨
$response->assertStatus(201);
$board = \Modules\Sirsoft\Board\Models\Board::where('slug', $slug)->firstOrFail();
$this->assertTrue($board->use_reaction);
$this->assertContains('like', $board->active_reaction_types ?? []);
}
/**
* is_active false로 게시판 생성 테스트
*/
@@ -1,108 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 관리자 반응 유형 목록 API Feature 테스트.
*
* GET admin/reaction-types 목록 조회, 다국어 라벨 폴백(확정 15), 시더 등록 확인,
* 권한 경계를 검증한다 (이슈 #525 §10 테스트 범위).
*/
class ReactionTypeApiTest extends ModuleTestCase
{
private const ENDPOINT = '/api/modules/sirsoft-board/admin/reaction-types';
protected function setUp(): void
{
parent::setUp();
// FK(restrictOnDelete) 순서: 이력 먼저 정리 후 유형 삭제 (타 테스트 잔여 데이터 대비)
DB::table('board_reactions')->delete();
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
}
/**
* 권한 있는 관리자는 활성 유형 목록을 display_order 순으로 조회한다.
*/
public function test_admin_can_list_active_reaction_types(): void
{
$admin = $this->createAdminUser(['sirsoft-board.settings.read']);
$response = $this->actingAs($admin)
->withHeader('Accept-Language', 'ko')
->getJson(self::ENDPOINT);
$response->assertOk()
->assertJsonPath('data.reaction_types.0.code', 'like')
->assertJsonPath('data.reaction_types.0.name', '추천')
->assertJsonPath('data.reaction_types.0.icon', 'fas fa-thumbs-up')
->assertJsonPath('data.reaction_types.0.icon_name', 'fa-thumbs-up')
->assertJsonPath('data.reaction_types.1.code', 'dislike')
->assertJsonPath('data.reaction_types.1.name', '비추천')
->assertJsonPath('data.reaction_types.1.icon_name', 'fa-thumbs-down');
}
/**
* en 로케일에서는 영어 라벨을 반환한다.
*
* @scenario case=reaction_type_label_localized
* @effects reaction_type_name_localized
*/
public function test_reaction_type_name_localized_to_en(): void
{
$admin = $this->createAdminUser(['sirsoft-board.settings.read']);
$this->actingAs($admin)
->withHeader('Accept-Language', 'en')
->getJson(self::ENDPOINT)
->assertOk()
->assertJsonPath('data.reaction_types.0.name', 'Recommend');
}
/**
* 미입력 언어는 사이트 기본 언어(fallback)로 대체 노출된다 (확정 15).
*
* @scenario case=reaction_type_label_falls_back
* @effects missing_locale_falls_back_to_default
*/
public function test_missing_locale_falls_back(): void
{
// ja 미입력 유형을 하나 추가해 폴백 경로를 직접 검증
ReactionType::create([
'code' => 'love',
'name' => ['ko' => '사랑', 'en' => 'Love'],
'icon' => 'fas fa-heart',
'display_order' => 3,
'is_active' => true,
]);
$admin = $this->createAdminUser(['sirsoft-board.settings.read']);
$response = $this->actingAs($admin)
->withHeader('Accept-Language', 'ja')
->getJson(self::ENDPOINT);
$response->assertOk();
$love = collect($response->json('data.reaction_types'))->firstWhere('code', 'love');
$this->assertNotNull($love);
// ja 미입력 → fallback_locale(en) 또는 첫 값으로 폴백 (빈 문자열 아님)
$this->assertNotSame('', $love['name']);
}
/**
* 권한 없는 사용자는 403 으로 차단된다.
*/
public function test_user_without_permission_forbidden(): void
{
$user = $this->createUser();
$this->actingAs($user)->getJson(self::ENDPOINT)
->assertForbidden();
}
}
@@ -1,325 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 반응 API Feature 테스트.
*
* 등록/전환/해제, reaction_counts 증감 정합성(전환 포함), use_reaction off·비활성 유형
* 차단, 본인 글 차단, 비로그인 차단을 종단 검증한다 (이슈 #525 §10 테스트 범위).
*
* @scenario case=guest_react_blocked
*
* @effects guest_react_returns_401
*/
class ReactionApiTest extends BoardTestCase
{
private int $likeId;
private int $dislikeId;
protected function setUp(): void
{
parent::setUp();
// FK(restrictOnDelete) 순서: 이력 먼저 정리 후 유형 삭제 (타 테스트 잔여 데이터 대비)
DB::table('board_reactions')->delete();
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$this->likeId = ReactionType::query()->where('code', 'like')->value('id');
$this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id');
$this->updateBoardSettings([
'use_reaction' => true,
'active_reaction_types' => ['like', 'dislike'],
]);
}
/**
* 반응 API 엔드포인트 URL 을 조립합니다.
*/
private function reactUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/react";
}
/**
* 반응 등록 → 카운트 +1, my_reaction_type_id 반영.
*/
public function test_register_reaction(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$response = $this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]);
$response->assertOk()
->assertJsonPath('data.action', 'add')
->assertJsonPath('data.my_reaction_type_id', $this->likeId)
->assertJsonPath("data.reaction_counts.{$this->likeId}", 1);
$this->assertDatabaseHas('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
'reaction_type_id' => $this->likeId,
]);
}
/**
* 다른 유형으로 전환 → 이전 -1·신규 +1 (증감 정합성).
*/
public function test_switch_reaction_adjusts_counts(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
$response = $this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId]);
$response->assertOk()
->assertJsonPath('data.action', 'change')
->assertJsonPath('data.my_reaction_type_id', $this->dislikeId)
->assertJsonPath("data.reaction_counts.{$this->likeId}", 0)
->assertJsonPath("data.reaction_counts.{$this->dislikeId}", 1);
}
/**
* 같은 유형 재클릭 → 해제, 카운트 -1, my_reaction_type_id null.
*/
public function test_remove_reaction_on_same_type(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
$response = $this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]);
$response->assertOk()
->assertJsonPath('data.action', 'remove')
->assertJsonPath('data.my_reaction_type_id', null)
->assertJsonPath("data.reaction_counts.{$this->likeId}", 0);
$this->assertDatabaseMissing('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
]);
}
/**
* 비로그인 요청은 401 로 차단된다 (확정 07).
*/
public function test_guest_cannot_react(): void
{
$author = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertUnauthorized();
}
/**
* use_reaction 이 꺼진 게시판은 422 로 차단된다.
*/
public function test_react_blocked_when_use_reaction_off(): void
{
$this->updateBoardSettings(['use_reaction' => false]);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertStatus(422);
}
/**
* 게시판이 켜지 않은(비활성) 유형은 검증 단계(422)에서 차단된다.
*/
public function test_react_blocked_for_inactive_type(): void
{
$this->updateBoardSettings(['active_reaction_types' => ['like']]);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId])
->assertStatus(422);
}
/**
* 본인 글에는 반응할 수 없다 (422, 확정 08).
*/
public function test_cannot_react_to_own_post(): void
{
$author = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($author, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertStatus(422);
}
/**
* 다른 게시판 소속이 아닌(존재하지 않는) 게시글은 404 로 차단된다.
*/
public function test_react_to_missing_post_returns_404(): void
{
$reactor = $this->createUser();
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl(999999), ['reaction_type_id' => $this->likeId])
->assertNotFound();
}
/**
* 비밀글에 열람 권한이 없는 사용자는 반응할 수 없다 (422, secret_denied).
*
* 회귀: 반응 서비스가 비밀글 열람 권한을 검사하지 않으면, 본문(content=null)을
* 못 보는 사용자가 추천/비추천만 남기는 우회가 가능하다. 신고(canViewSecretContent)와
* 동일하게 열람 권한 없는 비밀글 반응을 서버가 최종 차단해야 한다.
*
* @scenario case=secret_post_react_blocked
*
* @effects secret_post_without_permission_returns_422
*/
public function test_react_blocked_on_secret_post_without_permission(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost([
'user_id' => $author->id,
'is_secret' => true,
]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertStatus(422)
->assertJsonPath('errors.code', 'reaction_not_allowed');
// 반응 이력이 남지 않아야 한다
$this->assertDatabaseMissing('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
]);
}
/**
* 비밀글 열람 권한(posts.read-secret)이 있는 사용자는 비밀글에도 반응할 수 있다.
*
* 비밀글 가드가 열람 권한자까지 막지 않는지 확인한다 (신고 판정과 동일 기준).
*
* @scenario case=secret_post_react_allowed_with_permission
*
* @effects secret_post_with_read_secret_permission_allows_react
*/
public function test_react_allowed_on_secret_post_with_read_secret_permission(): void
{
$this->grantUserRolePermissions(['posts.read-secret']);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost([
'user_id' => $author->id,
'is_secret' => true,
]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk()
->assertJsonPath('data.action', 'add');
$this->assertDatabaseHas('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
'reaction_type_id' => $this->likeId,
]);
}
/**
* 게시글 상세 응답의 reaction_counts 가 유형 ID 키를 보존한다 (JSON 객체, 배열 재인덱싱 금지).
*
* 회귀: PostResource 의 reaction_counts 키가 정수 [1,2] 라 JsonResource::resolve() 가
* list 로 오인해 [count1, count2] 로 재인덱싱하면, 프론트가 reaction_counts[유형ID] 로
* 읽을 때 엉뚱한 인덱스를 읽어 개수가 항상 0/어긋나게 표시된다. 키는 항상 유형 ID 여야 한다.
*/
public function test_post_detail_reaction_counts_preserves_type_id_keys(): void
{
// 상세 조회는 posts.read 권한이 필요하다
$this->grantUserRolePermissions(['posts.read', 'posts.write']);
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $this->createUser()->id]);
// 추천 1회 등록
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
// 게시글 상세 조회 — reaction_counts 는 유형 ID 키로 개수를 담아야 한다
$detail = $this->actingAs($reactor, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
$detail->assertOk()
->assertJsonPath("data.reaction_counts.{$this->likeId}", 1)
->assertJsonPath("data.reaction_counts.{$this->dislikeId}", 0)
->assertJsonPath('data.my_reaction_type_id', $this->likeId);
// 키가 유형 ID 로 보존되는지 직접 확인 (0-기반 재인덱싱 아님)
$counts = $detail->json('data.reaction_counts');
$this->assertArrayHasKey((string) $this->likeId, $counts);
$this->assertArrayNotHasKey('0', $counts);
}
/**
* 반응 등록/전환/해제 시 활동 로그가 기록된다 (after_react 훅 → 리스너).
*
* @scenario case=react_logs_activity
*
* @effects react_logs_add_change_remove_activity
*/
public function test_react_writes_activity_log(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
// 등록 → reaction.add 로그
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
$this->assertDatabaseHas('activity_logs', ['action' => 'reaction.add']);
// 전환 → reaction.change 로그
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId])
->assertOk();
$this->assertDatabaseHas('activity_logs', ['action' => 'reaction.change']);
// 해제 → reaction.remove 로그
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId])
->assertOk();
$this->assertDatabaseHas('activity_logs', ['action' => 'reaction.remove']);
}
}
@@ -27,8 +27,6 @@ type BoardAuthFixtures = {
boardManageToken: string;
/** 게시판 조회 + 첨부 다운로드 권한 토큰 (#413-58b 행위자 기록 검증용) */
attachmentDownloadToken: string;
/** 게시판/게시글 조회 권한 토큰 (반응 등록/전환/해제 흐름 검증용, 반응은 로그인만 요구) */
reactionToken: string;
};
export const test = base.extend<BoardAuthFixtures>({
@@ -71,15 +69,6 @@ export const test = base.extend<BoardAuthFixtures>({
),
);
},
reactionToken: async ({}, use) => {
// 반응은 별도 권한 없이 로그인(auth:sanctum)만 요구. 게시글 조회 권한만 발급.
await use(
issueToken(
'sirsoft-board.boards.read',
'sirsoft-board.notice.posts.read',
),
);
},
});
export { authenticatePage };
@@ -1,141 +0,0 @@
/**
* 게시판 반응(추천/비추천) 사용자 흐름 — 등록 → 전환 → 해제 (이슈 #525).
*
* 게시글 상세에서 반응 버튼을 눌러 등록하고, 다른 유형으로 전환하고, 같은 유형을 재클릭해
* 해제하는 흐름을 브라우저에서 확인한다. 각 클릭 후 게시글 데이터소스를 재조회(refetchDataSource)
* 하므로, 서버 응답의 my_reaction_type_id·reaction_counts 가 버튼 하이라이트와 개수에 반영된다.
*
* 단위/레이아웃 테스트(ReactionServiceTest, ReactionApiTest, board-reaction-buttons.test.tsx)는
* 등록/전환/해제 판정·카운트 증감·렌더 분기를 검증하므로, 이 spec 은 실제 클릭 → API 호출 →
* 재조회 → 화면 갱신의 종단 흐름을 담당한다.
*
* @scenario surface=detail_register_switch_remove
* @effects detail_register_then_switch_then_remove_updates_ui,register_inserts_row_and_increments_count,switch_updates_row_and_adjusts_both_counts,remove_deletes_row_and_decrements_count,self_post_click_shows_denied_toast
*
* 활성화 절차: PlaywrightIssueToken 발급 + 반응 유형이 켜진 공개 게시판/게시글 시드가 가능한
* 환경에서 test.describe.skip → test.describe. SLUG/POST_ID 는 시드에 맞춰 조정.
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
const SLUG = 'free';
const POST_ID = 18;
const POST_PATH = `/board/${SLUG}/${POST_ID}`;
const REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${POST_ID}/react`;
// 유형 ID (시드 순서상 like=1, dislike=2 가정 — 시드에 맞춰 조정)
const LIKE_ID = 1;
const DISLIKE_ID = 2;
// reactionToken 발급 계정이 작성자인 게시글 ID (본인 글 반응 차단 검증용) — 시드에 맞춰 조정
const OWN_POST_ID = 19;
const OWN_POST_PATH = `/board/${SLUG}/${OWN_POST_ID}`;
const OWN_POST_REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${OWN_POST_ID}/react`;
test.describe('게시판 반응 등록→전환→해제 흐름 (#525)', () => {
// @scenario surface=detail_register_switch_remove
// @effects register_inserts_row_and_increments_count, switch_updates_row_and_adjusts_both_counts, remove_deletes_row_and_decrements_count
test('추천 등록 → 비추천 전환 → 비추천 해제 시 버튼 하이라이트·개수가 갱신된다', async ({
page,
reactionToken,
}) => {
await authenticatePage(page, reactionToken);
// react API 응답을 흐름 단계별로 제어 (등록/전환/해제)
const steps = [
{ action: 'add', my: LIKE_ID, counts: { [LIKE_ID]: 1, [DISLIKE_ID]: 0 } },
{ action: 'change', my: DISLIKE_ID, counts: { [LIKE_ID]: 0, [DISLIKE_ID]: 1 } },
{ action: 'remove', my: null, counts: { [LIKE_ID]: 0, [DISLIKE_ID]: 0 } },
];
let step = 0;
let reactBody: Record<string, unknown> | null = null;
await page.route(REACT_API, async (route) => {
reactBody = route.request().postDataJSON() as Record<string, unknown>;
const s = steps[Math.min(step, steps.length - 1)];
step += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'ok',
data: { action: s.action, my_reaction_type_id: s.my, reaction_counts: s.counts },
}),
});
});
await page.goto(POST_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const likeButton = page.getByRole('button', { name: /추천|Recommend/ }).first();
const dislikeButton = page.getByRole('button', { name: /비추천|Not Recommend/ }).first();
// 1) 추천 등록 → count +1, reaction_type_id = like
// 처리 중에는 버튼이 disabled(스피너) 되므로, 응답 도착 후 스피너가 완전히 걷히고
// 카운트 텍스트가 갱신될 때까지 기다린 다음 다음 클릭을 보낸다 — 그렇지 않으면 아직
// 리렌더 중인 버튼에 클릭이 씹혀 요청이 누락된다.
// step 자체를 폴링한다(2·3단계 모두 reaction_type_id=DISLIKE_ID 로 동일하므로,
// reactBody 값만 보면 세 번째 요청이 발생하지 않아도 이전 값과 우연히 일치해 통과해버린다).
await likeButton.click();
await expect.poll(() => step, { timeout: 5_000 }).toBe(1);
expect((reactBody as Record<string, unknown> | null)?.reaction_type_id).toBe(LIKE_ID);
await expect(likeButton).toHaveText(/추천\s*1/, { timeout: 5_000 });
await expect(dislikeButton).toBeEnabled({ timeout: 5_000 });
// 2) 비추천 전환 → like -1 · dislike +1
await dislikeButton.click();
await expect.poll(() => step, { timeout: 5_000 }).toBe(2);
expect((reactBody as Record<string, unknown> | null)?.reaction_type_id).toBe(DISLIKE_ID);
await expect(dislikeButton).toHaveText(/비추천\s*1/, { timeout: 5_000 });
await expect(dislikeButton).toBeEnabled({ timeout: 5_000 });
// 3) 비추천 재클릭 → 해제 (같은 유형 재요청)
await dislikeButton.click();
await expect.poll(() => step, { timeout: 5_000 }).toBe(3);
expect((reactBody as Record<string, unknown> | null)?.reaction_type_id).toBe(DISLIKE_ID);
});
// @scenario surface=detail_register_switch_remove
// @effects guest_react_returns_401
test('비로그인 상태에서 반응 클릭 시 로그인 안내 후 로그인 페이지로 이동한다', async ({ page }) => {
// 인증 없이 진입 (토큰 미주입)
await page.goto(POST_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const likeButton = page.getByRole('button', { name: /추천|Recommend/ }).first();
await likeButton.click();
// 비로그인 → 로그인 페이지로 이동 (redirect 파라미터로 원글 경로 전달)
await expect.poll(() => page.url(), { timeout: 5_000 }).toContain('/login');
expect(page.url()).toContain('redirect');
});
// @scenario surface=detail_register_switch_remove
// @effects self_post_click_shows_denied_toast
test('본인 글 반응 버튼 클릭 시 API 호출 없이 안내 토스트가 표시된다', async ({
page,
reactionToken,
}) => {
await authenticatePage(page, reactionToken);
// disabled 버튼은 클릭 이벤트 자체가 발생하지 않아 안내를 줄 수 없으므로, 본인 글에서는
// 버튼을 시각적으로만 비활성 톤으로 두고 클릭은 받아 switch 분기에서 토스트로 안내한다
// (트러블슈팅: PO 피드백 — disabled 버튼은 "왜 안 눌리는지" 사용자가 알 수 없음).
let reactCalled = false;
await page.route(OWN_POST_REACT_API, async (route) => {
reactCalled = true;
await route.abort();
});
await page.goto(OWN_POST_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const likeButton = page.getByRole('button', { name: /추천|Recommend/ }).first();
await likeButton.click();
await expect(page.getByText(/본인 글에는 반응할 수 없습니다|cannot react to your own post/i)).toBeVisible({
timeout: 5_000,
});
expect(reactCalled).toBe(false);
});
});
@@ -1,90 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Database\Seeders;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* BoardReactionTypeSeeder 검증.
*
* 시더 실행 후 추천(like)/비추천(dislike) 2건이 정확한 code·아이콘·ko/en/ja
* 라벨로 등록되는지 확인한다 (이슈 #525 1단계 시더 작성 검증).
*/
class BoardReactionTypeSeederTest extends ModuleTestCase
{
protected function setUp(): void
{
parent::setUp();
// FK(restrictOnDelete) 순서: 이력 먼저 정리 (타 테스트 잔여 데이터 대비)
DB::table('board_reactions')->delete();
}
/**
* 시더가 추천/비추천 2건을 정확한 값으로 등록한다.
*
* @scenario case=reaction_type_label_localized
* @effects reaction_type_name_localized
*/
public function test_seeder_creates_like_and_dislike_types(): void
{
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$this->assertSame(2, ReactionType::query()->count());
$like = ReactionType::query()->where('code', 'like')->first();
$this->assertNotNull($like);
$this->assertSame('fas fa-thumbs-up', $like->icon);
$this->assertSame(1, $like->display_order);
$this->assertTrue($like->is_active);
$this->assertSame(
['ko' => '추천', 'en' => 'Recommend', 'ja' => 'おすすめ'],
$like->name
);
$dislike = ReactionType::query()->where('code', 'dislike')->first();
$this->assertNotNull($dislike);
$this->assertSame('fas fa-thumbs-down', $dislike->icon);
$this->assertSame(2, $dislike->display_order);
$this->assertTrue($dislike->is_active);
$this->assertSame(
['ko' => '비추천', 'en' => 'Not Recommend', 'ja' => 'ひどい'],
$dislike->name
);
}
/**
* 시더는 code로 매칭하는 upsert라 재실행해도 중복 생성되지 않는다.
*/
public function test_seeder_is_idempotent(): void
{
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$this->seed(BoardReactionTypeSeeder::class);
$this->assertSame(2, ReactionType::query()->count());
}
/**
* 한글이 유니코드 이스케이프 없이 저장된다 (AsUnicodeJson 캐스트).
*/
public function test_name_stored_as_unicode_json(): void
{
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$raw = \Illuminate\Support\Facades\DB::table('board_reaction_types')
->where('code', 'like')
->value('name');
$this->assertStringContainsString('추천', $raw);
$this->assertStringNotContainsString('\\u', $raw);
}
}
@@ -1,153 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Repositories;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* ReactionRepository / ReactionTypeRepository 검증.
*
* upsert(등록/전환)·delete(해제)·recalculatePostReactionCounts(실제 행 기준 재집계) 및
* 유형 조회(getActive/findByCodes/findById/findByIds)를 검증한다.
*/
class ReactionRepositoryTest extends BoardTestCase
{
private ReactionRepositoryInterface $reactionRepository;
private ReactionTypeRepositoryInterface $reactionTypeRepository;
private int $likeId;
private int $dislikeId;
protected function setUp(): void
{
parent::setUp();
DB::table('board_reactions')->delete();
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$this->likeId = ReactionType::query()->where('code', 'like')->value('id');
$this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id');
$this->reactionRepository = app(ReactionRepositoryInterface::class);
$this->reactionTypeRepository = app(ReactionTypeRepositoryInterface::class);
}
/**
* upsert 는 없으면 INSERT, 있으면 유형만 UPDATE 한다 (사용자당 1행 유지).
*
* @scenario case=register_first_like
* @effects register_inserts_row_and_increments_count
*/
public function test_upsert_inserts_then_updates_single_row(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$first = $this->reactionRepository->upsert($user->id, 'post', $postId, $this->likeId, $this->board->id);
$this->assertSame($this->likeId, (int) $first->reaction_type_id);
$second = $this->reactionRepository->upsert($user->id, 'post', $postId, $this->dislikeId, $this->board->id);
$this->assertSame($first->id, $second->id);
$this->assertSame($this->dislikeId, (int) $second->reaction_type_id);
$this->assertSame(1, \Modules\Sirsoft\Board\Models\Reaction::query()
->where('user_id', $user->id)->where('target_id', $postId)->count());
}
/**
* findByUserAndTarget 은 사용자+대상 유일 키로 조회한다.
*/
public function test_find_by_user_and_target(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$this->reactionRepository->upsert($user->id, 'post', $postId, $this->likeId, $this->board->id);
$found = $this->reactionRepository->findByUserAndTarget($user->id, 'post', $postId);
$this->assertNotNull($found);
$this->assertSame($this->likeId, (int) $found->reaction_type_id);
$this->assertNull($this->reactionRepository->findByUserAndTarget($user->id, 'post', $postId + 999));
}
/**
* delete 는 이력 행을 삭제한다.
*/
public function test_delete_removes_reaction(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$reaction = $this->reactionRepository->upsert($user->id, 'post', $postId, $this->likeId, $this->board->id);
$this->assertTrue($this->reactionRepository->delete($reaction));
$this->assertNull($this->reactionRepository->findByUserAndTarget($user->id, 'post', $postId));
}
/**
* recalculatePostReactionCounts 는 board_reactions 실제 행 수를 그대로 반영한다
* (델타 누적이 아닌 COUNT 재집계 — 캐시가 실제 데이터와 항상 일치해야 함).
*/
public function test_recalculate_post_reaction_counts_reflects_actual_rows(): void
{
$postId = $this->createTestPost(['reaction_counts' => json_encode([(string) $this->likeId => 99])]);
// 실제 반응 행 없음 → 캐시에 남아있던 오염된 값(99)은 사라지고, 이전에
// 노출됐던 유형 키는 0으로 유지된다 (API 응답 계약 — 키 자체는 보존).
$counts = $this->reactionRepository->recalculatePostReactionCounts($postId);
$this->assertSame([(string) $this->likeId => 0], $counts);
$userA = $this->createUser();
$userB = $this->createUser();
$this->reactionRepository->upsert($userA->id, 'post', $postId, $this->likeId, $this->board->id);
$this->reactionRepository->upsert($userB->id, 'post', $postId, $this->dislikeId, $this->board->id);
$counts = $this->reactionRepository->recalculatePostReactionCounts($postId);
$this->assertSame(1, $counts[(string) $this->likeId]);
$this->assertSame(1, $counts[(string) $this->dislikeId]);
$post = Post::findOrFail($postId);
$this->assertSame($counts, $post->reaction_counts);
}
/**
* getActive 는 활성 유형을 display_order 순으로 반환한다.
*/
public function test_get_active_returns_ordered_active_types(): void
{
$active = $this->reactionTypeRepository->getActive();
$this->assertSame(['like', 'dislike'], $active->pluck('code')->all());
}
/**
* findByCodes 는 code 목록으로 활성 유형만 조회한다.
*/
public function test_find_by_codes(): void
{
$found = $this->reactionTypeRepository->findByCodes(['dislike']);
$this->assertSame(['dislike'], $found->pluck('code')->all());
$this->assertTrue($this->reactionTypeRepository->findByCodes([])->isEmpty());
}
/**
* findById / findByIds 로 유형을 조회한다.
*/
public function test_find_by_id_and_ids(): void
{
$this->assertSame('like', $this->reactionTypeRepository->findById($this->likeId)?->code);
$this->assertNull($this->reactionTypeRepository->findById(999999));
$byIds = $this->reactionTypeRepository->findByIds([$this->likeId, $this->dislikeId]);
$this->assertSame(['like', 'dislike'], $byIds->pluck('code')->all());
$this->assertTrue($this->reactionTypeRepository->findByIds([])->isEmpty());
}
}
@@ -1,276 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Services;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Exceptions\ReactionNotAllowedException;
use Modules\Sirsoft\Board\Models\Reaction;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
use Modules\Sirsoft\Board\Services\ReactionService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* ReactionService 검증.
*
* 등록/전환/해제, reaction_counts 증감 정합성(전환 포함), use_reaction off,
* 비활성 유형, 본인 글 차단, 스코프 검증을 검증한다 (이슈 #525 §10 테스트 범위).
*/
class ReactionServiceTest extends BoardTestCase
{
private ReactionService $service;
private int $likeId;
private int $dislikeId;
protected function setUp(): void
{
parent::setUp();
DB::table('board_reactions')->delete();
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$this->likeId = ReactionType::query()->where('code', 'like')->value('id');
$this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id');
$this->updateBoardSettings([
'use_reaction' => true,
'active_reaction_types' => ['like', 'dislike'],
]);
$this->service = app(ReactionService::class);
}
/**
* 반응이 없던 게시글에 반응하면 신규 등록되고 카운트가 +1 된다.
*
* @scenario case=register_first_like
* @effects register_inserts_row_and_increments_count
*/
public function test_react_adds_new_reaction_and_increments_count(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$result = $this->service->react($reactor->id, $this->board, $postId, $this->likeId);
$this->assertSame('add', $result['action']);
$this->assertSame($this->likeId, $result['reaction_type_id']);
$this->assertSame(1, $result['reaction_counts'][(string) $this->likeId]);
$this->assertDatabaseHas('board_reactions', [
'user_id' => $reactor->id,
'target_type' => 'post',
'target_id' => $postId,
'reaction_type_id' => $this->likeId,
]);
}
/**
* 다른 유형으로 전환하면 이전 유형 -1·신규 유형 +1 이 동시 반영된다 (단일 트랜잭션).
*
* @scenario case=switch_like_to_dislike
* @effects switch_updates_row_and_adjusts_both_counts, switch_count_atomic_in_single_transaction
*/
public function test_react_switches_type_and_adjusts_both_counts(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->service->react($reactor->id, $this->board, $postId, $this->likeId);
$result = $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->dislikeId);
$this->assertSame('change', $result['action']);
$this->assertSame(0, $result['reaction_counts'][(string) $this->likeId]);
$this->assertSame(1, $result['reaction_counts'][(string) $this->dislikeId]);
// 사용자당 1행 유지 (전환은 UPDATE)
$this->assertSame(1, Reaction::query()->where('user_id', $reactor->id)->where('target_id', $postId)->count());
$this->assertDatabaseHas('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
'reaction_type_id' => $this->dislikeId,
]);
}
/**
* 같은 유형을 재클릭하면 해제되어 이력 행이 삭제되고 카운트가 -1 된다.
*
* @scenario case=remove_same_type
* @effects remove_deletes_row_and_decrements_count
*/
public function test_react_same_type_removes_reaction_and_decrements_count(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->service->react($reactor->id, $this->board, $postId, $this->likeId);
$result = $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->likeId);
$this->assertSame('remove', $result['action']);
$this->assertNull($result['reaction_type_id']);
$this->assertSame(0, $result['reaction_counts'][(string) $this->likeId]);
$this->assertDatabaseMissing('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
]);
}
/**
* 카운트는 0 미만으로 내려가지 않는다 (해제 시 클램프).
*
* @scenario case=count_never_negative_on_over_remove
* @effects reaction_count_never_negative
*/
public function test_reaction_count_never_goes_below_zero(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id, 'reaction_counts' => json_encode([])]);
$this->service->react($reactor->id, $this->board, $postId, $this->likeId);
$result = $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->likeId);
$this->assertGreaterThanOrEqual(0, $result['reaction_counts'][(string) $this->likeId]);
}
/**
* 캐시된 reaction_counts 가 실제 board_reactions 행 수와 어긋나 있어도(예: 짧은
* 시간에 연속 전송된 요청이 서버 도착 순서와 다르게 처리되어 캐시가 오염된 경우),
* 다음 반응 처리가 실제 행을 재집계해 캐시를 실제 데이터와 일치하는 값으로
* 정정한다. 델타(증감) 누적 방식은 이런 오염을 스스로 고치지 못하고 계속
* 누적시키므로, 실제 COUNT 재집계 방식임을 검증한다.
*
* @scenario case=polluted_cache_self_heals_on_next_reaction
* @effects reaction_counts_matches_actual_rows_after_recalculation
*/
public function test_react_recalculates_from_actual_rows_and_heals_polluted_cache(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
// 캐시에는 실제와 무관한 오염된 값(양쪽 다 1)을 미리 심어둔다.
$postId = $this->createTestPost([
'user_id' => $author->id,
'reaction_counts' => json_encode([(string) $this->likeId => 1, (string) $this->dislikeId => 1]),
]);
$result = $this->service->react($reactor->id, $this->board, $postId, $this->likeId);
// 실제 반응 행은 이번에 등록된 like 1건뿐 — 오염된 dislike=1 은 사라지고
// 캐시가 실제 상태(like=1, dislike=0)로 정정되어야 한다.
$this->assertSame(1, $result['reaction_counts'][(string) $this->likeId]);
$this->assertSame(0, $result['reaction_counts'][(string) $this->dislikeId]);
$this->assertSame(
1,
Reaction::query()->where('target_id', $postId)->where('target_type', 'post')->count(),
);
}
/**
* use_reaction 이 꺼진 게시판은 반응이 차단된다.
*
* @scenario case=use_reaction_off_react_blocked
* @effects use_reaction_off_react_returns_422
*/
public function test_react_blocked_when_use_reaction_off(): void
{
$this->updateBoardSettings(['use_reaction' => false]);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->expectException(ReactionNotAllowedException::class);
$this->service->react($reactor->id, $this->board->fresh(), $postId, $this->likeId);
}
/**
* 게시판이 켜지 않은(비활성) 유형은 차단된다.
*
* @scenario case=inactive_type_react_blocked
* @effects inactive_type_react_returns_422
*/
public function test_react_blocked_for_inactive_type_on_board(): void
{
$this->updateBoardSettings(['active_reaction_types' => ['like']]);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->expectException(ReactionNotAllowedException::class);
$this->service->react($reactor->id, $this->board->fresh(), $postId, $this->dislikeId);
}
/**
* 본인 글에는 반응할 수 없다.
*
* @scenario case=self_post_react_blocked
* @effects self_post_react_returns_422
*/
public function test_react_blocked_on_own_post(): void
{
$author = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->expectException(ReactionNotAllowedException::class);
$this->service->react($author->id, $this->board, $postId, $this->likeId);
}
/**
* 다른 게시판 소속 게시글 ID 로는 반응할 수 없다 (스코프 검증).
*
* @scenario case=post_not_in_board
* @effects post_not_in_board_returns_404
*/
public function test_react_blocked_for_post_not_in_board(): void
{
$reactor = $this->createUser();
$this->expectException(PostNotFoundException::class);
$this->service->react($reactor->id, $this->board, 999999, $this->likeId);
}
/**
* 스코프 검증 통과 직후·락 획득 시점 사이에 게시글이 삭제되는 레이스 상황에서도
* PostNotFoundException 으로 일관되게 처리된다.
*
* lockPostForReaction() 은 findOrFail() 을 사용해 락을 거는데, 이 시점에 게시글이
* 없으면 Eloquent 의 ModelNotFoundException 이 던져진다. 이를 감싸지 않으면 컨트롤러의
* catch 블록 어디에도 걸리지 않아 일반 \Exception 으로 떨어져 500 에러가 되고, 사용자는
* "반응 처리에 실패했습니다"만 보게 되어 게시글이 없다는 실제 원인을 알 수 없다.
*
* @scenario case=post_deleted_between_scope_check_and_lock
* @effects post_not_in_board_returns_404
*/
public function test_react_wraps_model_not_found_during_lock_as_post_not_found(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$repository = $this->mock(ReactionRepositoryInterface::class);
$repository->shouldReceive('lockPostForReaction')
->once()
->with($postId)
->andThrow(new ModelNotFoundException());
$service = new ReactionService(
$repository,
app(\Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface::class),
app(\Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface::class),
);
$this->expectException(PostNotFoundException::class);
$service->react($reactor->id, $this->board, $postId, $this->likeId);
}
}
@@ -1,105 +0,0 @@
feature: 게시판 반응(추천/비추천)
description: |
이슈 #525 — 게시글에 반응(추천/비추천)을 남기는 기능. 로그인 회원이 게시글에 대해
게시판이 켠 반응 유형 중 하나를 선택해 남긴다. 공개 리액션이라 개수를 항상 노출하고,
본인 글 셀프 반응 제한·로그인 필수 등 어뷰징 방지 조건이 붙는다.
핵심 동작 (확정):
- 반응 유형은 DB 테이블(board_reaction_types)로 관리, 초기 시드 추천(like)/비추천(dislike)
- 글당 반응 1개 — 같은 유형 재클릭=해제(DELETE), 다른 유형=전환(UPDATE, 이전 -1·신규 +1)
- 카운트는 board_posts.reaction_counts JSON(키=유형 ID), 증감은 이력 쓰기와 단일 트랜잭션
- 게시판별 use_reaction on/off + active_reaction_types(code 배열) 로 노출 유형 결정
- 활성 유형이 0개면 반응 영역 자체가 미렌더 (use_reaction on 이어도)
- 비로그인 차단(401), 본인 글 차단(422), 비활성 유형 차단(422), 타 게시판 게시글 404
- 다국어 미입력 언어는 사이트 기본 언어로 폴백
# axis 의미:
# case 는 (기존 반응 상태 · 요청 유형 · 게시판 설정 · 사용자 상태) 의 의미 있는 조합을
# 한 축으로 열거한다. 독립 축을 곱하면 무의미한 조합(예: locale × guard)이 폭증하므로
# 기획서 확정 시나리오만 case 로 둔다 (board-conditional-validation.yaml 관례).
axes:
case:
- register_first_like
- switch_like_to_dislike
- remove_same_type
- count_never_negative_on_over_remove
- polluted_cache_self_heals_on_next_reaction
- guest_react_blocked
- self_post_react_blocked
- inactive_type_react_blocked
- use_reaction_off_react_blocked
- post_not_in_board
- post_deleted_between_scope_check_and_lock
- detail_shows_active_types_even_zero
- detail_hidden_when_no_active_types
- detail_hidden_when_use_reaction_off
- author_buttons_disabled
- settings_toggle_and_checkbox_persist
- new_board_seeds_reaction_defaults
- reaction_type_label_localized
- reaction_type_label_falls_back
- react_logs_activity
- react_failure_rolls_back
- react_pending_disables_button
- secret_post_react_blocked
- secret_post_react_allowed_with_permission
- reaction_area_hidden_on_inaccessible_secret_post
effects:
# 등록/전환/해제 + 카운트 증감 정합성
- register_inserts_row_and_increments_count
- switch_updates_row_and_adjusts_both_counts
- remove_deletes_row_and_decrements_count
- reaction_count_never_negative
- switch_count_atomic_in_single_transaction
- reaction_counts_matches_actual_rows_after_recalculation
# 어뷰징/상태 차단
- guest_react_returns_401
- self_post_react_returns_422
- inactive_type_react_returns_422
- use_reaction_off_react_returns_422
- post_not_in_board_returns_404
# 비밀글 열람 권한 가드 (신고와 동일 기준)
- secret_post_without_permission_returns_422
- secret_post_with_read_secret_permission_allows_react
- reaction_area_hidden_when_secret_content_inaccessible
# 화면 렌더 (확정 09·11)
- active_types_always_shown_even_zero_count
- reaction_area_hidden_when_no_active_types
- reaction_area_hidden_when_use_reaction_off
- author_sees_buttons_disabled
- my_reaction_type_highlighted
# 설정 (확정 02·13)
- settings_toggle_persists_use_reaction
- settings_checkbox_persists_active_reaction_types
- new_board_seeds_reaction_defaults_from_settings
# 다국어 (확정 15)
- reaction_type_name_localized
- missing_locale_falls_back_to_default
# 활동 로그
- react_logs_add_change_remove_activity
# 프론트 UX (낙관 하이라이트 + 응답값 개수 + 실패 롤백)
- reaction_failure_rolls_back_optimistic_state
- reaction_pending_disables_button
- self_post_click_shows_denied_toast
# E2E 흐름
- detail_register_then_switch_then_remove_updates_ui
test_files:
- modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Repositories/ReactionRepositoryTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/BoardReactionTypeSeederTest.php
- modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/ReactionTypeApiTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php
- templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx
- modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-reaction-settings.test.tsx
- modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-reaction-flow.spec.ts
validation:
audit_rule: frontend-change-requires-e2e
note: |
입력 조합 전수(case)는 백엔드/프론트/E2E 테스트가 @scenario/@effects 마킹으로 커버한다.
브라우저 수준의 등록→전환→해제 사용자 흐름은 board-reaction-flow.spec.ts 가 담당한다
(PlaywrightIssueToken 발급 가능 환경에서 describe.skip 해제).
@@ -54,7 +54,6 @@
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
- 마일리지 설정의 통화별 규칙에 적립 절사 기준(1점 / 10점 / 100점 단위 × 버림 / 반올림 / 올림)을 추가했습니다. 적립 포인트를 계산할 때와 부분취소로 주문이 나뉠 때 모두 이 기준이 적용됩니다. 기존 쇼핑몰은 지금까지와 같은 "1점 단위 버림"으로 시작하므로 적립액이 달라지지 않습니다. 이 기준은 주문 시점의 값이 함께 보관되어, 이후 기준을 바꿔도 과거 주문의 적립액이 소급해서 바뀌지 않습니다. 언어/통화 설정의 환율 절사와는 별개 항목입니다(그쪽은 외화 표시 환산에만 적용됩니다).
### Changed
@@ -5,7 +5,7 @@
*
* @description
* 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면,
* 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* 확장이 이 슬롯에 주입하는 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*
@@ -1,14 +0,0 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-28
### Added
- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다.
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다.
- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
@@ -1,47 +0,0 @@
프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,243 +0,0 @@
# Bizppurio Messaging Plugin for G7
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.0-blue" alt="Version">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.3-0066FF" alt="G7">
<img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>
비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다.
G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다.
[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 연동](#알림톡-템플릿-연동) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
---
## 주요 기능
- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택
- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영
- 알림톡 미승인/미연결 시 문자로 자동 대체발송(옵션)
- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용)
- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록
- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증
- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용)
- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시
- 알림톡 템플릿 목록·상태·내용 조회 및 알림 연결 (템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행)
- 알림톡 템플릿이 카카오에서 삭제·차단·승인취소된 경우 "사용 불가 — 재연결 필요" 표시
---
## 요구 사항
| 구분 | 항목 | 내용 |
|------|------|------|
| 플랫폼 | G7 | `>= 7.0.3` |
| 플랫폼 | PHP | `^8.2` |
| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 |
| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) |
| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 + 발송할 템플릿의 카카오 검수 승인 |
> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다.
---
## 설치
플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
```text
plugins/sirsoft-message_bizppurio
```
프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
```bash
npm install
npm run build
```
그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워집니다.
---
## 관리자 설정
관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다.
| 설정 | 필수 여부 | 설명 |
|------|:---:|------|
| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 |
| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 |
| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 |
| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 |
| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 |
| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 |
| 알림톡 내용 캐시 시간(분) | 선택 (기본 60) | 카카오 템플릿 내용 재사용 시간. 0이면 매 발송마다 최신 조회, [캐시 초기화] 버튼으로 즉시 반영 가능 |
> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
---
## webhook(발송 결과 리포트) 등록
비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다.
> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.**
```text
https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
```
정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다.
---
## 발송 흐름
```text
코어/모듈이 알림 발화 (예: 회원가입, 주문 완료)
→ 알림 설정에 연결된 문자·알림톡 채널로 발송 작업 큐잉
→ 알림톡: 카카오 승인 템플릿 사용, 미승인/미연결 시 문자로 대체발송(옵션)
→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택
→ 비즈뿌리오 발송 API 호출
→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보
→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록
```
일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다.
---
## 알림톡 템플릿 연동
카카오 알림톡 템플릿의 **등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서** 진행합니다. 이 플러그인의 설정 화면(알림톡 템플릿 탭)은 콘솔에 등록된 템플릿의 목록·상태·내용을 조회하고, 발송가능(승인) 상태의 템플릿을 알림에 연결하는 역할만 담당합니다.
| 상태 | 발송 가능 | 설명 |
|:---:|:---:|------|
| 발송가능 | ✅ | 카카오 검수 승인 완료, 알림에 연결해 발송 가능 |
| 검수중 | ❌ | 카카오 검수 진행 중 |
| 반려 | ❌ | 카카오 검수 반려, 콘솔에서 재신청 필요 |
| 미검수 | ❌ | 콘솔에 등록만 되고 검수 신청 전 상태 |
| 중지 | ❌ | 사용 중지된 템플릿 |
알림에 연결한 템플릿이 이후 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요"가 표시됩니다. 이 경우 알림톡 템플릿 탭에서 다른 승인 템플릿으로 다시 연결해야 합니다.
---
## 발송 결과 코드
비즈뿌리오/카카오가 반환하는 결과 코드는 4가지로 분류되어 처리됩니다.
| 분류 | 처리 방침 |
|:---:|------|
| 성공 | 발송 완료 |
| 재시도 (일시 오류) | 자동 재시도 대상 (예: 카카오 시스템 오류, 처리 지연, 게이트웨이 오류) |
| 잔액 부족 | 즉시 실패 처리 + 관리자 자체 알림 |
| 영구 실패 | 즉시 실패 처리, 재시도하지 않음 |
주요 코드 예시:
| 코드 | 분류 | 사유 |
|:---:|------|------|
| `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 |
| `9070` | 잔액 부족 | 잔액 부족(문자) |
| `9071` | 잔액 부족 | 후불 한도 초과 |
| `7436` | 잔액 부족 | 지갑 잔액 부족(알림톡) |
| `4400` | 영구 실패 | 음영 지역 |
| `7103` | 영구 실패 | 발신 프로필 키 무효 |
발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 목록은 `lang/ko/result_codes.php` / `lang/en/result_codes.php`에 정의되어 있으며, lang에 없는 코드는 코드만 표시됩니다.
---
## 가용 훅 (Hook)
다른 모듈이나 플러그인에서 아래 훅에 연결해 잔액부족 상황을 확장 처리할 수 있습니다.
### 액션 훅
| 훅 이름 | 시점 | 인수 |
|------|------|------|
| `sirsoft-message_bizppurio.balance.low` | 잔액 부족·한도 초과로 발송 실패 시 (쿨다운 내 최초 1회) | `string $resultCode, string $channel` |
### 훅 등록 예시
```php
use App\Extension\HookManager;
HookManager::addAction(
'sirsoft-message_bizppurio.balance.low',
function (string $resultCode, string $channel) {
// 예: 잔액 부족 시 Slack으로도 별도 알림
SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}");
},
priority: 10
);
```
`$resultCode`는 잔액 부족(`9070` 문자 / `7436` 알림톡) 또는 후불 한도 초과(`9071`) 코드입니다. 이 훅은 관리자 자체 알림(잔액부족/후불한도초과 안내)을 발화하는 지점과 동일하며, 채널별 쿨다운(기본 3600초) 동안 한 번만 실행됩니다.
---
## API
전체 엔드포인트 레퍼런스는 [docs/api/README.md](docs/api/README.md)를 참고하세요.
| 문서 | 내용 |
|------|------|
| [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 |
| [templates.md](docs/api/templates.md) | 알림톡 템플릿 조회 |
| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 템플릿 관리자 API |
| [notification-bindings.md](docs/api/notification-bindings.md) | 알림-템플릿 연결 |
| [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 |
| [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 |
### 권한
| 권한 | 설명 |
|------|------|
| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 |
| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 연결, 캐시 초기화 등 관리 작업 |
---
## 삭제 시 동작
플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다.
---
## 보안 및 운영 참고
- 비즈뿌리오 비밀번호, API 키, 알림톡 발신프로필 키는 외부에 노출하지 마세요. 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인하세요. 운영 모드는 실제 발송과 비용이 발생합니다.
- webhook URL을 비즈뿌리오 콘솔에 등록하지 않으면 발송 결과 확인이 불가능합니다.
- 검수와 운영은 별도의 비즈뿌리오 계정 사용을 권장합니다.
- 지갑 잔액/후불 한도를 주기적으로 확인하세요. 부족 시 관리자 알림이 발송되지만, 알림 자체도 같은 채널(문자/알림톡)을 사용하지 않는 별도 채널(예: 사이트 내 알림, 메일)로 함께 받는 것을 권장합니다.
---
## 테스트
플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
```bash
php artisan test plugins/sirsoft-message_bizppurio/tests
```
프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
```bash
npm install
npm run test:run
npm run build
```
---
## 라이선스
MIT
@@ -1,10 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-message_bizppurio",
"version": "1.0.0",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -1,22 +0,0 @@
{
"name": "plugins/sirsoft-message_bizppurio",
"description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft",
"type": "library",
"version": "1.0.0",
"license": "MIT",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"]
}
}
}
@@ -1,23 +0,0 @@
{
"_meta": {
"version": "1.0.0",
"description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false."
},
"defaults": {
"is_test_mode": true,
"bizppurio_id": "",
"password": "",
"api_key": "",
"sender_number": "",
"sender_key": "",
"balance_low_notify_cooldown": 3600
},
"frontend_schema": {
"is_test_mode": { "expose": false },
"bizppurio_id": { "expose": false },
"password": { "expose": false, "sensitive": true },
"api_key": { "expose": false, "sensitive": true },
"sender_number": { "expose": false },
"sender_key": { "expose": false, "sensitive": true }
}
}
@@ -1,73 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 비즈뿌리오 발송 이력 테이블.
*
* 문자(SMS/LMS)·알림톡 1건 발송마다 1행. 발송 시 pending 으로 생성되고,
* 비즈뿌리오 webhook(URL PUSH) 리포트 수신 시 refkey 로 매칭해 상태를 갱신한다.
* 발송 이력 화면(계획서 §6-5)의 데이터소스.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('bizppurio_dispatches');
Schema::create('bizppurio_dispatches', function (Blueprint $table) {
$table->bigIncrements('id')->comment('발송 이력 PK');
$table->string('refkey', 32)->comment('우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)');
$table->string('messagekey', 64)->nullable()->comment('비즈뿌리오 부여 키 (발송 응답 messagekey)');
$table->string('channel', 20)->comment('발송 채널: sms / lms / alimtalk (DispatchChannel enum)');
$table->string('media', 10)->nullable()->comment('webhook MEDIA — 실제 발송 유형: SMS / LMS / KAT(알림톡)');
$table->string('to_number', 20)->comment('수신 전화번호 (숫자만)');
$table->string('to_name', 100)->nullable()->comment('수신자명 (발송 시점 스냅샷)');
$table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null')
->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete();
$table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)');
$table->json('request_payload')->nullable()->comment('실제 비즈뿌리오 API 전송 요청 payload (개인식별 정보 제외 — to/refkey/type/본문 텍스트는 다른 컬럼과 중복이라 제외, account/from/senderkey/templatecode/버튼 등 요소만 저장)');
$table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null');
$table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다');
$table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)');
$table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)');
$table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)');
$table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)');
$table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)');
$table->boolean('is_test_mode')->nullable()->comment('발송 시점 검수 모드 여부 (null=컬럼 신설 이전 이력)');
$table->timestamp('sent_at')->nullable()->comment('발송 시각');
$table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)');
$table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드');
$table->timestamps();
$table->unique('refkey', 'bizppurio_dispatch_refkey_unique');
$table->index('channel', 'bizppurio_dispatch_channel_idx');
$table->index('status', 'bizppurio_dispatch_status_idx');
$table->index('notification_type', 'bizppurio_dispatch_notif_type_idx');
$table->index('sent_at', 'bizppurio_dispatch_sent_at_idx');
$table->index('to_user_id', 'bizppurio_dispatch_user_idx');
$table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx');
$table->index('notification_log_id', 'bizppurio_dispatch_notif_log_idx');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('bizppurio_dispatches', function (Blueprint $table) {
$table->comment('비즈뿌리오 문자·알림톡 발송 이력');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bizppurio_dispatches');
}
};
@@ -1,51 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 테이블.
*
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장한다.
* 변수 매핑 컬럼은 없다 — 변수명 동일 규칙으로 발송 시 자동 치환한다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('bizppurio_notification_bindings');
Schema::create('bizppurio_notification_bindings', function (Blueprint $table) {
$table->bigIncrements('id')->comment('연결 설정 PK');
$table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)');
$table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)');
$table->string('template_code', 50)->comment('연결한 카카오 알림톡 템플릿 코드');
$table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)');
$table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)');
$table->boolean('is_active')->default(true)->comment('연동 활성 여부');
$table->timestamps();
$table->unique(['notification_type', 'channel'], 'bizppurio_binding_type_channel_unique');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('bizppurio_notification_bindings', function (Blueprint $table) {
$table->comment('비즈뿌리오 이벤트↔알림톡 템플릿 연결 설정');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bizppurio_notification_bindings');
}
};
@@ -1 +0,0 @@
var SirsoftMessageBizppurio=function(r){"use strict";const i={},t="sirsoft-message_bizppurio",o=window.G7Core?.createLogger?.(`Plugin:${t}`)??{log:(...e)=>console.log(`[Plugin:${t}]`,...e),warn:(...e)=>console.warn(`[Plugin:${t}]`,...e),error:(...e)=>console.error(`[Plugin:${t}]`,...e)};function a(e){Object.entries(i).forEach(([n,c])=>{e.registerHandler(`${t}.${n}`,c,{category:"plugin",source:t})}),o.log(`${Object.keys(i).length} handler(s) registered:`,Object.keys(i).map(n=>`${t}.${n}`))}function g(e){const n=window.G7Core?.getActionDispatcher?.();if(n){a(n);return}if(!e){o.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let c=0;const l=50,u=()=>{const d=window.G7Core?.getActionDispatcher?.();if(d){a(d);return}++c<=l?setTimeout(u,100):o.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};u()}function s(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>g(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();g(!e)}}return s(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:t,handlers:Object.keys(i),initPlugin:s}),r.initPlugin=s,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),r}({});
@@ -1,20 +0,0 @@
# API 레퍼런스 문서 목차
> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반).
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
<!-- @generated:start:api-readme-index -->
- **문서 수**: 7 · **엔드포인트 수**: 13
| 문서 | 도메인 | 엔드포인트 |
| --- | --- | --- |
| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 4 |
| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 |
| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 |
| [report.md](report.md) | `report` | 1 |
| [templates.md](templates.md) | `templates` | 1 |
| [token.md](token.md) | `token` | 1 |
| [webhook.md](webhook.md) | `webhook` | 1 |
<!-- @generated:end -->
@@ -1,224 +0,0 @@
# Alimtalk Templates API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@show`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) |
**응답 예시**
```json
{
"success": true,
"message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"data": { "cleared": 3 }
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
**설명**
발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다.
@@ -1,329 +0,0 @@
# Dispatch Results API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Dispatch Results 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@lookup`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| notification_log_ids | body | array | 아니오 | max 100 | notification log 식별자 배열 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"notification_log_ids": [
"예시값"
]
}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| results | array | `[]` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"results": []
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
**설명**
코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(layout_extensions overlay)이 소비하는 조회 API 다.
현재 페이지에 표시된 코어 알림 로그 id 배열(`notification_log_ids`, 최대 100)을 넘기면, 그 로그에
연결된 비즈뿌리오 발송 결과를 **로그 id 를 키로 하는 맵**으로 한 번에 돌려준다(행마다 개별 호출하지
않아 N+1 을 피한다). 코어 알림 로그 테이블은 수정하지 않으며, 연결 표식은 비즈뿌리오 쪽
(`bizppurio_dispatches.notification_log_id`)에만 둔다.
매칭되지 않는 로그 id(메일·사이트내알림 등 비-비즈뿌리오 발송)는 결과 맵에서 제외된다 — 화면에서는
그 행의 결과 컬럼이 빈 셀이 된다. 결과에는 전화번호 등 민감정보를 포함하지 않는다(결과 컬럼은 상태·
사유만 표시).
**응답 필드** (`data.results` — 키는 `notification_log_id`, 값은 결과 객체)
| 필드 | 타입 | 용도 |
| --- | --- | --- |
| status | string\|null | 발송 상태: `sent` / `success` / `failed` (DispatchStatus) |
| status_label | string\|null | 로케일 상태 라벨 (예: "성공", "실패", "발송중") |
| result_code | string\|null | 결과 코드 (발송응답 또는 webhook 리포트). 리포트 미수신 시 null |
| result_label | string\|null | `사유 (코드)` 표시 라벨 (예: "음영 지역 (4400)"). 코드 없으면 null |
| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 |
| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null |
| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` |
| content | string\|null | 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 `notification_logs.body`(대체발송용 코어 템플릿 값)와 달리 카카오 승인 템플릿의 실제 내용이므로, 화면은 이 값을 채널별로 구분해 별도 노출한다 |
**응답 예시**
```json
{
"success": true,
"message": "성공",
"data": {
"results": {
"128": {
"status": "failed",
"status_label": "실패",
"result_code": "4400",
"result_label": "음영 지역 (4400)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
}
}
}
}
```
### GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@recent`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"results": {
"21": {
"status": "pending",
"status_label": "대기",
"result_code": null,
"result_label": null,
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"20": {
"status": "success",
"status_label": "성공",
"result_code": "7000",
"result_label": "성공 (7000)",
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"19": {
"status": "failed",
"status_label": "실패",
"result_code": "7206",
"result_label": "검수되지 않은 템플릿 (7206)",
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"18": {
"status": "failed",
"status_label": "실패",
"result_code": "7436",
"result_label": "지갑 잔액 부족(알림톡) (7436)",
"is_low_balance": true,
"fallback_status": "실패",
"channel": "alimtalk"
},
"17": {
"status": "success",
"status_label": "성공",
"result_code": "7000",
"result_label": "성공 (7000)",
"is_low_balance": false,
"fallback_status": "성공",
"channel": "alimtalk"
},
"16": {
"status": "success",
"status_label": "성공",
"result_code": "7000",
"result_label": "성공 (7000)",
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"15": {
"status": "failed",
"status_label": "실패",
"result_code": "6603",
"result_label": "음영 지역 (6603)",
"is_low_balance": false,
"fallback_status": null,
"channel": "lms"
},
"14": {
"status": "success",
"status_label": "성공",
"result_code": "6600",
"result_label": "성공 (6600)",
"is_low_balance": false,
"fallback_status": null,
"channel": "lms"
},
"13": {
"status": "failed",
"status_label": "실패",
"result_code": "9999",
"result_label": "9999",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"12": {
"status": "failed",
"status_label": "실패",
"result_code": "9070",
"result_label": "잔액 부족(문자) (9070)",
"is_low_balance": true,
"fallback_status": null,
"channel": "sms"
},
"11": {
"status": "success",
"status_label": "성공",
"result_code": "4100",
"result_label": "성공 (4100)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"10": {
"status": "pending",
"status_label": "대기",
"result_code": null,
"result_label": null,
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"9": {
"status": "sent",
"status_label": "발송중",
"result_code": null,
"result_label": null,
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"8": {
"status": "failed",
"status_label": "실패",
"result_code": "4410",
"result_label": "잘못된 번호 (4410)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"7": {
"status": "failed",
"status_label": "실패",
"result_code": "4400",
"result_label": "음영 지역 (4400)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"6": {
"status": "success",
"status_label": "성공",
"result_code": "4100",
"result_label": "성공 (4100)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
}
}
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->

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