feat(board): 게시글 반응(추천/비추천) 백엔드
DB 스키마 + 백엔드 로직 + API 레이어를 구현.
- board_reaction_types/board_reactions 테이블 신규, boards.use_reaction·
active_reaction_types, board_posts.reaction_counts 컬럼 추가
- ReactionType(다국어 JSON+폴백)/Reaction 모델, Repository 2종(인터페이스 주입)
- ReactionService: 등록/전환/해제 통합, DB::transaction 원자 카운트,
use_reaction·비활성유형·본인글·slug 스코프 차단, before/after_react 훅
- POST boards/{slug}/posts/{postId}/react(회원) + GET admin/reaction-types
- ReactPostRequest + AvailableReactionType Rule, PostResource/BoardResource 확장
- 반응 활동 로그(add/change/remove) + ko/en 다국어 + API 레퍼런스 문서
- 테스트 30건(Unit/Feature, 87 assertions) green, 마이그레이션 왕복 안전성 검증
프론트엔드 화면·ja 언어팩·CHANGELOG·버전 bump 는 후속 단계(4~5)에서 진행.
This commit is contained in:
@@ -19,6 +19,8 @@
|
||||
"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,
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<?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,9 +44,10 @@ class DatabaseSeeder extends Seeder
|
||||
$this->command->info('');
|
||||
|
||||
// 설치 필수 시더 (항상 실행)
|
||||
$this->command->info('[설치] 게시판 타입 생성');
|
||||
$this->command->info('[설치] 게시판 타입 / 반응 유형 생성');
|
||||
$this->call([
|
||||
BoardTypeSeeder::class,
|
||||
BoardReactionTypeSeeder::class,
|
||||
]);
|
||||
$this->command->info('');
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| [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 |
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# 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 아이콘 클래스 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "반응 유형 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"reaction_types": [
|
||||
{ "id": 1, "code": "like", "name": "추천", "icon": "fas fa-thumbs-up" },
|
||||
{ "id": 2, "code": "dislike", "name": "비추천", "icon": "fas fa-thumbs-down" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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)
|
||||
* @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 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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?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,6 +57,9 @@ 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,
|
||||
|
||||
@@ -266,6 +269,35 @@ 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,
|
||||
])->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 확장자를 배열로 반환합니다.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -77,6 +78,12 @@ 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),
|
||||
];
|
||||
@@ -341,6 +348,8 @@ 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),
|
||||
@@ -480,6 +489,53 @@ class PostResource extends BaseApiResource
|
||||
->hasUserReported($user->id, $this->board->id, 'post', $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응 카운트를 응답용으로 반환합니다.
|
||||
*
|
||||
* 게시판이 켠(활성) 유형 전체를 키로 항상 포함하며, 저장된 카운트가 없는 유형은
|
||||
* 0 으로 채웁니다 (확정 09 — 활성 유형은 개수 0이어도 항상 노출). 키는 유형 ID 문자열.
|
||||
*
|
||||
* @return array<string, int> 유형 ID 문자열 => 개수
|
||||
*/
|
||||
private function getReactionCountsForResponse(): array
|
||||
{
|
||||
if (! $this->relationLoaded('board') || ! $this->board) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$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 $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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 상세 페이지 전용: 조건부 필드 메서드
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -29,9 +30,11 @@ class BoardActivityLogListener implements HookListenerInterface
|
||||
|
||||
/**
|
||||
* @param ReportRepositoryInterface $reportRepository 신고 bulk lookup
|
||||
* @param ReactionTypeRepositoryInterface $reactionTypeRepository 반응 유형 라벨 조회
|
||||
*/
|
||||
public function __construct(
|
||||
protected ReportRepositoryInterface $reportRepository,
|
||||
protected ReactionTypeRepositoryInterface $reactionTypeRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -83,6 +86,9 @@ 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],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -715,4 +721,52 @@ class BoardActivityLogListener implements HookListenerInterface
|
||||
'description_params' => ['report_id' => $report->id],
|
||||
]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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,6 +127,8 @@ class Board extends Model
|
||||
'use_reply',
|
||||
'max_reply_depth',
|
||||
'use_report',
|
||||
'use_reaction',
|
||||
'active_reaction_types',
|
||||
'new_display_hours',
|
||||
|
||||
// 입력 제한 설정
|
||||
@@ -183,6 +185,9 @@ 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,6 +79,7 @@ class Post extends Model implements FulltextSearchable
|
||||
'replies_count',
|
||||
'comments_count',
|
||||
'attachments_count',
|
||||
'reaction_counts',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -99,6 +100,8 @@ 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',
|
||||
@@ -107,6 +110,8 @@ class Post extends Model implements FulltextSearchable
|
||||
|
||||
/**
|
||||
* 게시판과의 관계를 정의합니다.
|
||||
*
|
||||
* @return BelongsTo<Board, Post>
|
||||
*/
|
||||
public function board(): BelongsTo
|
||||
{
|
||||
@@ -115,6 +120,8 @@ class Post extends Model implements FulltextSearchable
|
||||
|
||||
/**
|
||||
* 작성자와의 관계를 정의합니다 (회원).
|
||||
*
|
||||
* @return BelongsTo<User, Post>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
@@ -123,6 +130,8 @@ class Post extends Model implements FulltextSearchable
|
||||
|
||||
/**
|
||||
* 부모 게시글과의 관계를 정의합니다 (답글용).
|
||||
*
|
||||
* @return BelongsTo<Post, Post>
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
@@ -134,6 +143,8 @@ class Post extends Model implements FulltextSearchable
|
||||
*
|
||||
* board_id 조건은 Eager loading 호환을 위해 관계에서 제외하고
|
||||
* Repository의 Eager loading 클로저에서 명시적으로 전달합니다.
|
||||
*
|
||||
* @return HasMany<Post>
|
||||
*/
|
||||
public function replies(): HasMany
|
||||
{
|
||||
@@ -145,6 +156,8 @@ class Post extends Model implements FulltextSearchable
|
||||
*
|
||||
* board_id 조건은 Eager loading 호환을 위해 관계에서 제외하고
|
||||
* Repository의 Eager loading 클로저에서 명시적으로 전달합니다.
|
||||
*
|
||||
* @return HasMany<Comment>
|
||||
*/
|
||||
public function comments(): HasMany
|
||||
{
|
||||
@@ -157,6 +170,8 @@ class Post extends Model implements FulltextSearchable
|
||||
*
|
||||
* board_id 조건은 Eager loading 호환을 위해 관계에서 제외하고
|
||||
* Repository의 Eager loading 클로저에서 명시적으로 전달합니다.
|
||||
*
|
||||
* @return HasMany<Attachment>
|
||||
*/
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,13 @@ 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;
|
||||
@@ -77,6 +81,8 @@ 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,
|
||||
];
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 반응을 등록하거나 전환합니다 (없으면 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)를 유형별 증감으로 원자 갱신합니다.
|
||||
*
|
||||
* 반드시 트랜잭션 안에서 호출되어야 하며, 대상 행을 `lockForUpdate` 로 잠가
|
||||
* 동시 반응에도 카운트 누락이 없도록 read-modify-write 를 원자 처리합니다.
|
||||
* 결과 카운트는 0 미만으로 내려가지 않도록 클램프합니다.
|
||||
*
|
||||
* @param int $postId 게시글 ID
|
||||
* @param array<int, int> $deltas 유형 ID => 증감값 (예: [1 => -1, 2 => 1])
|
||||
* @return array<string, int> 갱신 후 reaction_counts (키는 유형 ID 문자열)
|
||||
*/
|
||||
public function adjustPostReactionCounts(int $postId, array $deltas): array;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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 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 adjustPostReactionCounts(int $postId, array $deltas): array
|
||||
{
|
||||
/** @var Post $post */
|
||||
$post = Post::query()->lockForUpdate()->findOrFail($postId);
|
||||
|
||||
$counts = $post->reaction_counts ?? [];
|
||||
|
||||
foreach ($deltas as $typeId => $delta) {
|
||||
$key = (string) $typeId;
|
||||
$next = (int) ($counts[$key] ?? 0) + $delta;
|
||||
$counts[$key] = max(0, $next);
|
||||
}
|
||||
|
||||
$post->reaction_counts = $counts;
|
||||
$post->save();
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Services;
|
||||
|
||||
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\Models\Post;
|
||||
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
|
||||
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
|
||||
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 반응 서비스
|
||||
*
|
||||
* 게시글 반응의 등록/전환/해제를 단일 진입점(`react()`)으로 처리합니다.
|
||||
* 확정 03(글당 1개, 전환 시 대체), 07(로그인 필수 — 컨트롤러 미들웨어),
|
||||
* 08(본인 글 차단), 11(비활성 유형 차단)을 강제합니다.
|
||||
*/
|
||||
class ReactionService
|
||||
{
|
||||
/**
|
||||
* 현재 지원하는 반응 대상 타입 (확정 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();
|
||||
}
|
||||
|
||||
// 요청 유형이 존재하는 활성 유형이면서 게시판이 켠 유형인지 확인 (확정 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) {
|
||||
$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->adjustPostReactionCounts(
|
||||
$post->id,
|
||||
[$reactionTypeId => -1],
|
||||
);
|
||||
|
||||
return ['action' => 'remove', 'reaction_type_id' => null, 'reaction_counts' => $counts];
|
||||
}
|
||||
|
||||
// 다른 유형 → 전환 (이전 -1, 신규 +1)
|
||||
if ($existing !== null) {
|
||||
$previousTypeId = (int) $existing->reaction_type_id;
|
||||
$this->reactionRepository->upsert(
|
||||
$userId,
|
||||
self::TARGET_POST,
|
||||
$post->id,
|
||||
$reactionTypeId,
|
||||
$board->id,
|
||||
);
|
||||
$counts = $this->reactionRepository->adjustPostReactionCounts(
|
||||
$post->id,
|
||||
[$previousTypeId => -1, $reactionTypeId => 1],
|
||||
);
|
||||
|
||||
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->adjustPostReactionCounts(
|
||||
$post->id,
|
||||
[$reactionTypeId => 1],
|
||||
);
|
||||
|
||||
return ['action' => 'add', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts];
|
||||
});
|
||||
|
||||
HookManager::doAction(
|
||||
'sirsoft-board.reaction.after_react',
|
||||
$userId,
|
||||
$post,
|
||||
$reactionTypeId,
|
||||
$result['action'],
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ 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',
|
||||
@@ -14,6 +16,7 @@ return [
|
||||
'delete' => 'Deleted',
|
||||
'delete_content' => 'Content Deleted',
|
||||
'download' => 'Downloaded',
|
||||
'remove' => 'Reaction Removed',
|
||||
'remove_from_menu' => 'Removed from Menu',
|
||||
'restore' => 'Restored',
|
||||
'restore_content' => 'Content Restored',
|
||||
@@ -69,6 +72,11 @@ 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,6 +124,19 @@ 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.',
|
||||
'login_required' => 'You need to sign in to use this feature.',
|
||||
],
|
||||
|
||||
// Additional comment messages
|
||||
'comments' => [
|
||||
'comments_disabled' => 'Comments are disabled for this board.',
|
||||
|
||||
@@ -307,6 +307,8 @@ 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',
|
||||
@@ -369,6 +371,9 @@ return [
|
||||
'process_note' => 'Process Note',
|
||||
'ids' => 'Report IDs',
|
||||
],
|
||||
'reaction' => [
|
||||
'reaction_type_id' => 'Reaction Type',
|
||||
],
|
||||
'blind' => [
|
||||
'reason' => 'Blind Reason',
|
||||
],
|
||||
@@ -580,6 +585,12 @@ 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,8 +4,10 @@ return [
|
||||
// 액션 라벨 (마지막 세그먼트 기준).
|
||||
// ActivityLog::getActionLabelAttribute 가 모듈 origin 라벨을 자체 lang 에서 우선 조회.
|
||||
'action' => [
|
||||
'add' => '반응 등록',
|
||||
'add_to_menu' => '메뉴 추가',
|
||||
'blind' => '블라인드',
|
||||
'change' => '반응 변경',
|
||||
'blind_content' => '콘텐츠 블라인드',
|
||||
'bulk_apply' => '일괄 적용',
|
||||
'bulk_apply_aborted' => '일괄 적용 중단',
|
||||
@@ -14,6 +16,7 @@ return [
|
||||
'delete' => '삭제',
|
||||
'delete_content' => '콘텐츠 삭제',
|
||||
'download' => '다운로드',
|
||||
'remove' => '반응 취소',
|
||||
'remove_from_menu' => '메뉴 제거',
|
||||
'restore' => '복원',
|
||||
'restore_content' => '콘텐츠 복원',
|
||||
@@ -69,6 +72,11 @@ 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,6 +124,19 @@ return [
|
||||
'post_deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.',
|
||||
],
|
||||
|
||||
// 반응(추천/비추천) 관련 메시지
|
||||
'reaction' => [
|
||||
'list_success' => '반응 유형 목록을 조회했습니다.',
|
||||
'add_success' => '반응을 남겼습니다.',
|
||||
'change_success' => '반응을 변경했습니다.',
|
||||
'remove_success' => '반응을 취소했습니다.',
|
||||
'failed' => '반응 처리에 실패했습니다.',
|
||||
'disabled' => '이 게시판은 반응 기능이 비활성화되어 있습니다.',
|
||||
'inactive_type' => '이 게시판에서 사용할 수 없는 반응 유형입니다.',
|
||||
'self_post' => '본인 글에는 반응할 수 없습니다.',
|
||||
'login_required' => '로그인이 필요한 기능입니다.',
|
||||
],
|
||||
|
||||
// 댓글 관련 추가 메시지
|
||||
'comments' => [
|
||||
'comments_disabled' => '이 게시판은 댓글 기능이 비활성화되어 있습니다.',
|
||||
|
||||
@@ -307,6 +307,8 @@ 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' => '최소 내용 길이',
|
||||
@@ -369,6 +371,9 @@ return [
|
||||
'process_note' => '처리 메모',
|
||||
'ids' => '신고 ID',
|
||||
],
|
||||
'reaction' => [
|
||||
'reaction_type_id' => '반응 유형',
|
||||
],
|
||||
'blind' => [
|
||||
'reason' => '블라인드 사유',
|
||||
],
|
||||
@@ -580,6 +585,12 @@ return [
|
||||
],
|
||||
|
||||
// 신고 검증 메시지
|
||||
'reaction' => [
|
||||
'reaction_type_id' => [
|
||||
'required' => '반응 유형은 필수입니다.',
|
||||
'integer' => '반응 유형은 정수여야 합니다.',
|
||||
],
|
||||
],
|
||||
'report' => [
|
||||
'invalid_status_transition' => '현재 신고 상태에서는 해당 상태로 변경할 수 없습니다.',
|
||||
'status' => [
|
||||
|
||||
@@ -8,11 +8,13 @@ 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;
|
||||
|
||||
@@ -126,6 +128,11 @@ 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');
|
||||
@@ -540,6 +547,10 @@ 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');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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.1.code', 'dislike')
|
||||
->assertJsonPath('data.reaction_types.1.name', '비추천');
|
||||
}
|
||||
|
||||
/**
|
||||
* en 로케일에서는 영어 라벨을 반환한다.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?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 테스트 범위).
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?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건을 정확한 값으로 등록한다.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?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(해제)·adjustPostReactionCounts(원자 카운트) 및
|
||||
* 유형 조회(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행 유지).
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* adjustPostReactionCounts 는 유형별 증감을 적용하고 0 미만으로 내려가지 않는다.
|
||||
*/
|
||||
public function test_adjust_post_reaction_counts_applies_deltas_and_clamps(): void
|
||||
{
|
||||
$postId = $this->createTestPost(['reaction_counts' => json_encode([(string) $this->likeId => 2])]);
|
||||
|
||||
$counts = $this->reactionRepository->adjustPostReactionCounts($postId, [
|
||||
$this->likeId => -1,
|
||||
$this->dislikeId => 1,
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $counts[(string) $this->likeId]);
|
||||
$this->assertSame(1, $counts[(string) $this->dislikeId]);
|
||||
|
||||
// 클램프: 0 아래로 내려가지 않음
|
||||
$clamped = $this->reactionRepository->adjustPostReactionCounts($postId, [$this->likeId => -5]);
|
||||
$this->assertSame(0, $clamped[(string) $this->likeId]);
|
||||
|
||||
$post = Post::findOrFail($postId);
|
||||
$this->assertSame(0, $post->reaction_counts[(string) $this->likeId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Unit\Services;
|
||||
|
||||
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\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 된다.
|
||||
*/
|
||||
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 이 동시 반영된다 (단일 트랜잭션).
|
||||
*/
|
||||
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 된다.
|
||||
*/
|
||||
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 미만으로 내려가지 않는다 (해제 시 클램프).
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* use_reaction 이 꺼진 게시판은 반응이 차단된다.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판이 켜지 않은(비활성) 유형은 차단된다.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인 글에는 반응할 수 없다.
|
||||
*/
|
||||
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 로는 반응할 수 없다 (스코프 검증).
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user