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 committed 2026-08-10 16:28:03 +09:00
1 parent f70c41c9ee
commit d3a62a7e04
232 files changed
+25 -26042

No files matched your search

@@ -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 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*