feat: FCM 관리자 페이지 추가

- FCM 토큰 관리 페이지 (목록, 활성화/비활성화, 삭제)
- 테스트 발송 페이지 (대상 필터, 미리보기, 발송)
- 발송 이력 페이지 (필터링, 결과 확인)
- FcmSender 서비스 (HTTP v1, 배치 처리)
- fcm_send_logs 테이블 마이그레이션
- google/auth 패키지 추가
This commit is contained in:
2025-12-19 09:04:42 +09:00
parent 43e469b444
commit c073b82633
24 changed files with 1946 additions and 8 deletions

View File

@@ -0,0 +1,337 @@
<?php
namespace App\Http\Controllers;
use App\Models\FcmSendLog;
use App\Models\PushDeviceToken;
use App\Models\Tenants\Tenant;
use App\Services\Fcm\FcmSender;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class FcmController extends Controller
{
public function __construct(
private readonly FcmSender $fcmSender
) {}
/**
* FCM 토큰 관리 페이지
*/
public function tokens(Request $request): View
{
$tenants = Tenant::orderBy('company_name')->get();
$tokens = $this->getTokensQuery($request)->paginate(20);
$stats = $this->getTokenStats($request->get('tenant_id'));
return view('fcm.tokens', compact('tenants', 'tokens', 'stats'));
}
/**
* FCM 토큰 목록 (HTMX partial)
*/
public function tokenList(Request $request): View
{
$tokens = $this->getTokensQuery($request)->paginate(20);
return view('fcm.partials.token-table', compact('tokens'));
}
/**
* 토큰 통계 (HTMX partial)
*/
public function tokenStats(Request $request): View
{
$stats = $this->getTokenStats($request->get('tenant_id'));
return view('fcm.partials.token-stats', compact('stats'));
}
/**
* 토큰 상태 변경 (활성/비활성)
*/
public function toggleToken(Request $request, int $id): View
{
$token = PushDeviceToken::withoutGlobalScopes()->findOrFail($id);
$token->update([
'is_active' => ! $token->is_active,
]);
return view('fcm.partials.token-row', compact('token'));
}
/**
* 토큰 삭제
*/
public function deleteToken(int $id): Response
{
$token = PushDeviceToken::withoutGlobalScopes()->findOrFail($id);
$token->delete();
return response('', 200)
->header('HX-Trigger', 'tokenDeleted');
}
/**
* FCM 테스트 발송 페이지
*/
public function send(Request $request): View
{
$tenants = Tenant::orderBy('company_name')->get();
return view('fcm.send', compact('tenants'));
}
/**
* FCM 발송 실행 (HTMX)
*/
public function sendPush(Request $request): View
{
$request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string|max:1000',
'tenant_id' => 'nullable|integer|exists:tenants,id',
'user_id' => 'nullable|integer',
'platform' => 'nullable|string|in:android,ios,web',
'channel_id' => 'nullable|string|max:50',
'type' => 'nullable|string|max:50',
'url' => 'nullable|string|max:500',
'sound_key' => 'nullable|string|max:50',
]);
// 대상 토큰 조회
$query = PushDeviceToken::withoutGlobalScopes()->active();
if ($tenantId = $request->get('tenant_id')) {
$query->forTenant($tenantId);
}
if ($userId = $request->get('user_id')) {
$query->forUser($userId);
}
if ($platform = $request->get('platform')) {
$query->platform($platform);
}
$tokens = $query->pluck('token')->toArray();
$tokenCount = count($tokens);
if ($tokenCount === 0) {
return view('fcm.partials.send-result', [
'success' => false,
'message' => '발송 대상 토큰이 없습니다.',
]);
}
// 발송 로그 생성
$sendLog = FcmSendLog::create([
'tenant_id' => $request->get('tenant_id'),
'user_id' => $request->get('user_id'),
'sender_id' => auth()->id(),
'title' => $request->get('title'),
'body' => $request->get('body'),
'channel_id' => $request->get('channel_id', 'push_default'),
'type' => $request->get('type'),
'platform' => $request->get('platform'),
'data' => array_filter([
'type' => $request->get('type'),
'url' => $request->get('url'),
'sound_key' => $request->get('sound_key'),
]),
'status' => FcmSendLog::STATUS_SENDING,
]);
try {
// FCM 발송
$data = array_filter([
'type' => $request->get('type'),
'url' => $request->get('url'),
'sound_key' => $request->get('sound_key'),
]);
$result = $this->fcmSender->sendToMany(
$tokens,
$request->get('title'),
$request->get('body'),
$request->get('channel_id', 'push_default'),
$data
);
// 무효 토큰 비활성화
$invalidTokens = $result->getInvalidTokens();
if (count($invalidTokens) > 0) {
foreach ($invalidTokens as $token) {
$response = $result->getResponse($token);
$errorCode = $response?->getErrorCode();
PushDeviceToken::withoutGlobalScopes()
->where('token', $token)
->update([
'is_active' => false,
'last_error' => $errorCode,
'last_error_at' => now(),
]);
}
}
// 발송 로그 업데이트
$summary = $result->toSummary();
$sendLog->markAsCompleted($summary);
return view('fcm.partials.send-result', [
'success' => true,
'message' => '발송 완료',
'data' => $summary,
]);
} catch (\Exception $e) {
$sendLog->markAsFailed($e->getMessage());
return view('fcm.partials.send-result', [
'success' => false,
'message' => '발송 중 오류 발생: '.$e->getMessage(),
]);
}
}
/**
* FCM 발송 이력 페이지
*/
public function history(Request $request): View
{
$logs = $this->getHistoryQuery($request)->paginate(20);
return view('fcm.history', compact('logs'));
}
/**
* FCM 발송 이력 목록 (HTMX partial)
*/
public function historyList(Request $request): View
{
$logs = $this->getHistoryQuery($request)->paginate(20);
return view('fcm.partials.history-table', compact('logs'));
}
/**
* 대상 토큰 수 미리보기 (HTMX)
*/
public function previewCount(Request $request): View
{
$query = PushDeviceToken::withoutGlobalScopes()->active();
if ($tenantId = $request->get('tenant_id')) {
$query->forTenant($tenantId);
}
if ($userId = $request->get('user_id')) {
$query->forUser($userId);
}
if ($platform = $request->get('platform')) {
$query->platform($platform);
}
$count = $query->count();
return view('fcm.partials.preview-count', compact('count'));
}
/**
* 토큰 쿼리 빌더
*/
private function getTokensQuery(Request $request)
{
$query = PushDeviceToken::withoutGlobalScopes()
->with(['user:id,name,email', 'tenant:id,company_name']);
if ($tenantId = $request->get('tenant_id')) {
$query->where('tenant_id', $tenantId);
}
if ($platform = $request->get('platform')) {
$query->where('platform', $platform);
}
if ($request->has('is_active') && $request->get('is_active') !== '') {
$query->where('is_active', $request->boolean('is_active'));
}
if ($request->boolean('has_error')) {
$query->hasError();
}
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('token', 'like', "%{$search}%")
->orWhere('device_name', 'like', "%{$search}%")
->orWhereHas('user', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
return $query->orderBy('created_at', 'desc');
}
/**
* 토큰 통계 가져오기
*/
private function getTokenStats(?int $tenantId = null): array
{
$query = PushDeviceToken::withoutGlobalScopes();
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
$total = (clone $query)->count();
$active = (clone $query)->where('is_active', true)->count();
$inactive = (clone $query)->where('is_active', false)->count();
$hasError = (clone $query)->whereNotNull('last_error')->count();
$byPlatform = (clone $query)->selectRaw('platform, count(*) as count')
->groupBy('platform')
->pluck('count', 'platform')
->toArray();
return [
'total' => $total,
'active' => $active,
'inactive' => $inactive,
'has_error' => $hasError,
'by_platform' => $byPlatform,
];
}
/**
* 발송 이력 쿼리 빌더
*/
private function getHistoryQuery(Request $request)
{
$query = FcmSendLog::with(['sender:id,name', 'tenant:id,company_name']);
if ($tenantId = $request->get('tenant_id')) {
$query->where('tenant_id', $tenantId);
}
if ($status = $request->get('status')) {
$query->where('status', $status);
}
if ($from = $request->get('from')) {
$query->whereDate('created_at', '>=', $from);
}
if ($to = $request->get('to')) {
$query->whereDate('created_at', '<=', $to);
}
return $query->orderBy('created_at', 'desc');
}
}