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:
chym1217
2026-08-07 17:00:41 +09:00
parent 3d0f260653
commit 4060a91d28
41 changed files with 2331 additions and 1 deletions
@@ -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,
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
});
}
}
};
@@ -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');
}
});
}
}
};
@@ -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,
];
@@ -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;
}
@@ -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();
}
}
@@ -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);
}
}