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

@@ -67,3 +67,11 @@ VITE_APP_NAME="${APP_NAME}"
# Google Gemini API (SAM AI 음성 어시스턴트)
GEMINI_API_KEY=
GEMINI_PROJECT_ID=
# FCM (Firebase Cloud Messaging)
FCM_PROJECT_ID=
FCM_SA_PATH=secrets/firebase-service-account.json
FCM_BATCH_CHUNK_SIZE=200
FCM_BATCH_DELAY_MS=100
FCM_LOGGING_ENABLED=true
FCM_LOG_CHANNEL=stack

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');
}
}

View File

@@ -43,8 +43,11 @@ class ApiDeprecation extends Model
* 상태 상수
*/
public const STATUS_CANDIDATE = 'candidate'; // 삭제 후보
public const STATUS_SCHEDULED = 'scheduled'; // 삭제 예정
public const STATUS_DEPRECATED = 'deprecated'; // 폐기됨 (사용 중단)
public const STATUS_REMOVED = 'removed'; // 완전 삭제
/**

135
app/Models/FcmSendLog.php Normal file
View File

@@ -0,0 +1,135 @@
<?php
namespace App\Models;
use App\Models\Tenants\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FcmSendLog extends Model
{
/**
* 상태 상수
*/
public const STATUS_PENDING = 'pending';
public const STATUS_SENDING = 'sending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'tenant_id',
'user_id',
'sender_id',
'title',
'body',
'channel_id',
'type',
'platform',
'data',
'total_count',
'success_count',
'failure_count',
'invalid_token_count',
'success_rate',
'status',
'error_message',
'completed_at',
];
protected $casts = [
'data' => 'array',
'total_count' => 'integer',
'success_count' => 'integer',
'failure_count' => 'integer',
'invalid_token_count' => 'integer',
'success_rate' => 'decimal:2',
'completed_at' => 'datetime',
];
/**
* 발송자 (MNG 관리자)
*/
public function sender(): BelongsTo
{
return $this->belongsTo(User::class, 'sender_id');
}
/**
* 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* 대상 사용자
*/
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Scope: 특정 발송자의 로그
*/
public function scopeBySender($query, int $senderId)
{
return $query->where('sender_id', $senderId);
}
/**
* Scope: 특정 상태
*/
public function scopeByStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* Scope: 최근 N일
*/
public function scopeRecent($query, int $days = 7)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
/**
* 발송 시작 표시
*/
public function markAsSending(): void
{
$this->update(['status' => self::STATUS_SENDING]);
}
/**
* 발송 완료 표시
*/
public function markAsCompleted(array $summary): void
{
$this->update([
'status' => self::STATUS_COMPLETED,
'total_count' => $summary['total'],
'success_count' => $summary['success'],
'failure_count' => $summary['failure'],
'invalid_token_count' => $summary['invalid_tokens'],
'success_rate' => $summary['success_rate'],
'completed_at' => now(),
]);
}
/**
* 발송 실패 표시
*/
public function markAsFailed(string $errorMessage): void
{
$this->update([
'status' => self::STATUS_FAILED,
'error_message' => $errorMessage,
'completed_at' => now(),
]);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Models;
use App\Models\Tenants\Tenant;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PushDeviceToken extends Model
{
use BelongsToTenant;
use SoftDeletes;
protected $fillable = [
'tenant_id',
'user_id',
'token',
'platform',
'device_name',
'app_version',
'is_active',
'last_used_at',
'last_error',
'last_error_at',
];
protected $casts = [
'is_active' => 'boolean',
'last_used_at' => 'datetime',
'last_error_at' => 'datetime',
];
/**
* 플랫폼 상수
*/
public const PLATFORM_IOS = 'ios';
public const PLATFORM_ANDROID = 'android';
public const PLATFORM_WEB = 'web';
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 테넌트 관계
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
/**
* Scope: 활성 토큰만
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope: 플랫폼별 필터
*/
public function scopePlatform($query, string $platform)
{
return $query->where('platform', $platform);
}
/**
* Scope: 특정 사용자의 토큰
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: 특정 테넌트의 토큰 (global scope 무시)
*/
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
/**
* Scope: 에러가 있는 토큰
*/
public function scopeHasError($query)
{
return $query->whereNotNull('last_error');
}
/**
* 에러 정보 기록
*/
public function recordError(string $errorCode): void
{
$this->update([
'last_error' => $errorCode,
'last_error_at' => now(),
]);
}
/**
* 토큰 비활성화 (에러와 함께)
*/
public function deactivate(?string $errorCode = null): void
{
$data = ['is_active' => false];
if ($errorCode) {
$data['last_error'] = $errorCode;
$data['last_error_at'] = now();
}
$this->update($data);
}
}

View File

@@ -46,21 +46,21 @@ public function getApiUsageComparison(): array
// 사용된 API 통계
$usageStats = $this->getUsageStats()->keyBy(function ($item) {
return $item->endpoint . '|' . $item->method;
return $item->endpoint.'|'.$item->method;
});
// 폐기 후보 목록
$deprecations = ApiDeprecation::active()
->get()
->keyBy(function ($item) {
return $item->endpoint . '|' . $item->method;
return $item->endpoint.'|'.$item->method;
});
$used = [];
$unused = [];
foreach ($allEndpoints as $endpoint) {
$key = $endpoint['path'] . '|' . $endpoint['method'];
$key = $endpoint['path'].'|'.$endpoint['method'];
$stats = $usageStats->get($key);
$deprecation = $deprecations->get($key);
@@ -229,13 +229,13 @@ public function getStaleApis(int $days = 30): Collection
->where('created_at', '>=', $cutoffDate)
->distinct()
->get()
->map(fn ($item) => $item->endpoint . '|' . $item->method)
->map(fn ($item) => $item->endpoint.'|'.$item->method)
->toArray();
// 전체 사용 통계에서 최근 사용된 것 제외
return $this->getUsageStats()
->filter(function ($item) use ($recentlyUsed) {
$key = $item->endpoint . '|' . $item->method;
$key = $item->endpoint.'|'.$item->method;
return ! in_array($key, $recentlyUsed);
});

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Fcm;
class FcmBatchResult
{
/** @var array<string, FcmResponse> */
private array $responses = [];
private int $successCount = 0;
private int $failureCount = 0;
/** @var array<string> 무효 토큰 목록 */
private array $invalidTokens = [];
/**
* 응답 추가
*/
public function addResponse(string $token, FcmResponse $response): void
{
$this->responses[$token] = $response;
if ($response->success) {
$this->successCount++;
} else {
$this->failureCount++;
if ($response->isInvalidToken()) {
$this->invalidTokens[] = $token;
}
}
}
/**
* 전체 발송 수
*/
public function getTotal(): int
{
return count($this->responses);
}
/**
* 성공 수
*/
public function getSuccessCount(): int
{
return $this->successCount;
}
/**
* 실패 수
*/
public function getFailureCount(): int
{
return $this->failureCount;
}
/**
* 무효 토큰 목록 (비활성화 필요)
*
* @return array<string>
*/
public function getInvalidTokens(): array
{
return $this->invalidTokens;
}
/**
* 모든 응답
*
* @return array<string, FcmResponse>
*/
public function getResponses(): array
{
return $this->responses;
}
/**
* 특정 토큰의 응답
*/
public function getResponse(string $token): ?FcmResponse
{
return $this->responses[$token] ?? null;
}
/**
* 성공률 (%)
*/
public function getSuccessRate(): float
{
if ($this->getTotal() === 0) {
return 0.0;
}
return round(($this->successCount / $this->getTotal()) * 100, 2);
}
/**
* 요약 정보
*/
public function toSummary(): array
{
return [
'total' => $this->getTotal(),
'success' => $this->successCount,
'failure' => $this->failureCount,
'invalid_tokens' => count($this->invalidTokens),
'success_rate' => $this->getSuccessRate(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services\Fcm;
use Exception;
class FcmException extends Exception
{
public function __construct(
string $message,
public readonly int $httpStatusCode = 0,
public readonly array $response = [],
?\Throwable $previous = null
) {
parent::__construct($message, 0, $previous);
}
/**
* HTTP 응답에서 예외 생성
*/
public static function fromResponse(int $statusCode, array $response): self
{
$message = $response['error']['message'] ?? 'Unknown FCM error';
return new self($message, $statusCode, $response);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Services\Fcm;
class FcmResponse
{
public function __construct(
public readonly bool $success,
public readonly ?string $messageId,
public readonly ?string $error,
public readonly int $statusCode,
public readonly array $rawResponse
) {}
/**
* 성공 응답 생성
*/
public static function success(?string $messageId, int $statusCode, array $rawResponse): self
{
return new self(
success: true,
messageId: $messageId,
error: null,
statusCode: $statusCode,
rawResponse: $rawResponse
);
}
/**
* 실패 응답 생성
*/
public static function failure(string $error, int $statusCode, array $rawResponse): self
{
return new self(
success: false,
messageId: null,
error: $error,
statusCode: $statusCode,
rawResponse: $rawResponse
);
}
/**
* FCM 에러 코드 추출
*/
public function getErrorCode(): ?string
{
if ($this->success) {
return null;
}
return $this->rawResponse['error']['details'][0]['errorCode'] ?? null;
}
/**
* 토큰이 유효하지 않은지 확인 (삭제 필요)
*/
public function isInvalidToken(): bool
{
if ($this->success) {
return false;
}
// FCM에서 반환하는 무효 토큰 에러 코드들
$invalidTokenErrors = [
'UNREGISTERED',
'INVALID_ARGUMENT',
'NOT_FOUND',
];
return in_array($this->getErrorCode(), $invalidTokenErrors, true);
}
/**
* 배열로 변환
*/
public function toArray(): array
{
return [
'success' => $this->success,
'message_id' => $this->messageId,
'error' => $this->error,
'status_code' => $this->statusCode,
];
}
}

View File

@@ -0,0 +1,273 @@
<?php
namespace App\Services\Fcm;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FcmSender
{
private const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
private ?string $accessToken = null;
private ?int $tokenExpiresAt = null;
/**
* 단일 토큰에 푸시 발송
*/
public function sendToToken(
string $token,
string $title,
string $body,
string $channelId = 'push_default',
array $data = []
): FcmResponse {
$message = $this->buildMessage($token, $title, $body, $channelId, $data);
return $this->send($message);
}
/**
* 다건 발송 (토큰 배열) - 단순 루프
*
* @param array<string> $tokens
* @return array<FcmResponse>
*/
public function sendToTokens(
array $tokens,
string $title,
string $body,
string $channelId = 'push_default',
array $data = []
): array {
$responses = [];
foreach ($tokens as $token) {
$responses[] = $this->sendToToken($token, $title, $body, $channelId, $data);
}
return $responses;
}
/**
* 대량 발송 (chunk + rate limit 지원)
*
* @param array<string> $tokens
*/
public function sendToMany(
array $tokens,
string $title,
string $body,
string $channelId = 'push_default',
array $data = [],
?int $chunkSize = null,
?int $delayMs = null
): FcmBatchResult {
$chunkSize = $chunkSize ?? config('fcm.batch.chunk_size', 200);
$delayMs = $delayMs ?? config('fcm.batch.delay_ms', 100);
$result = new FcmBatchResult;
$chunks = array_chunk($tokens, $chunkSize);
$totalChunks = count($chunks);
foreach ($chunks as $index => $chunk) {
foreach ($chunk as $token) {
$response = $this->sendToToken($token, $title, $body, $channelId, $data);
$result->addResponse($token, $response);
}
// 마지막 chunk가 아니면 rate limit delay
if ($index < $totalChunks - 1 && $delayMs > 0) {
usleep($delayMs * 1000);
}
}
return $result;
}
/**
* FCM 메시지 빌드
*/
private function buildMessage(
string $token,
string $title,
string $body,
string $channelId,
array $data
): array {
$message = [
'message' => [
'token' => $token,
'notification' => [
'title' => $title,
'body' => $body,
],
'android' => [
'priority' => config('fcm.defaults.priority', 'high'),
'ttl' => config('fcm.defaults.ttl', '86400s'),
'notification' => [
'channel_id' => $channelId,
'sound' => 'default',
],
],
'apns' => [
'payload' => [
'aps' => [
'sound' => 'default',
'badge' => 1,
],
],
],
],
];
// 커스텀 데이터 추가
if (! empty($data)) {
// FCM data는 모두 string이어야 함
$stringData = array_map(fn ($v) => is_string($v) ? $v : json_encode($v), $data);
$message['message']['data'] = $stringData;
}
return $message;
}
/**
* FCM HTTP v1 API로 메시지 발송
*/
private function send(array $message): FcmResponse
{
$projectId = config('fcm.project_id');
if (empty($projectId)) {
return FcmResponse::failure('FCM_PROJECT_ID not configured', 0, $message);
}
$endpoint = str_replace('{project_id}', $projectId, config('fcm.endpoint'));
try {
$accessToken = $this->getAccessToken();
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$accessToken,
'Content-Type' => 'application/json',
])->post($endpoint, $message);
$statusCode = $response->status();
$responseBody = $response->json() ?? [];
$this->logRequest($message, $statusCode, $responseBody);
if ($response->successful()) {
return FcmResponse::success($responseBody['name'] ?? null, $statusCode, $responseBody);
}
$errorMessage = $responseBody['error']['message'] ?? 'Unknown FCM error';
return FcmResponse::failure($errorMessage, $statusCode, $responseBody);
} catch (\Exception $e) {
$this->logError($message, $e);
return FcmResponse::failure($e->getMessage(), 0, []);
}
}
/**
* OAuth2 Access Token 발급 (캐싱)
*/
private function getAccessToken(): string
{
// 토큰이 유효하면 재사용
if ($this->accessToken && $this->tokenExpiresAt && time() < $this->tokenExpiresAt - 60) {
return $this->accessToken;
}
$saPath = $this->getServiceAccountPath();
if (! file_exists($saPath)) {
throw new FcmException("Service account file not found: {$saPath}");
}
$credentials = new ServiceAccountCredentials(
self::FCM_SCOPE,
json_decode(file_get_contents($saPath), true)
);
$token = $credentials->fetchAuthToken();
if (empty($token['access_token'])) {
throw new FcmException('Failed to fetch FCM access token');
}
$this->accessToken = $token['access_token'];
$this->tokenExpiresAt = time() + ($token['expires_in'] ?? 3600);
return $this->accessToken;
}
/**
* Service Account JSON 파일 경로
*/
private function getServiceAccountPath(): string
{
$path = config('fcm.service_account_path');
// 절대 경로인 경우
if (str_starts_with($path, '/')) {
return $path;
}
// 상대 경로인 경우 storage_path 기준
return storage_path($path);
}
/**
* 요청/응답 로깅
*/
private function logRequest(array $message, int $statusCode, array $response): void
{
if (! config('fcm.logging.enabled', true)) {
return;
}
$channel = config('fcm.logging.channel', 'stack');
$token = $message['message']['token'] ?? 'unknown';
$maskedToken = substr($token, 0, 20).'...';
if ($statusCode >= 200 && $statusCode < 300) {
Log::channel($channel)->info('FCM message sent', [
'token' => $maskedToken,
'status' => $statusCode,
'message_name' => $response['name'] ?? null,
]);
} else {
Log::channel($channel)->warning('FCM message failed', [
'token' => $maskedToken,
'status' => $statusCode,
'error' => $response['error'] ?? $response,
]);
}
}
/**
* 에러 로깅
*/
private function logError(array $message, \Exception $e): void
{
if (! config('fcm.logging.enabled', true)) {
return;
}
$channel = config('fcm.logging.channel', 'stack');
$token = $message['message']['token'] ?? 'unknown';
$maskedToken = substr($token, 0, 20).'...';
Log::channel($channel)->error('FCM send exception', [
'token' => $maskedToken,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}

View File

@@ -8,6 +8,7 @@
"require": {
"php": "^8.2",
"darkaonline/l5-swagger": "^9.0",
"google/auth": "^1.49",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1",

127
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c2faf899d8caff5078bf8fea2f6b6691",
"content-hash": "58e14bcea16068801af821062d7f3612",
"packages": [
{
"name": "brick/math",
@@ -666,6 +666,69 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v6.11.1",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v6.11.1"
},
"time": "2025-04-09T20:32:01+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -737,6 +800,68 @@
],
"time": "2025-12-03T09:33:47+00:00"
},
{
"name": "google/auth",
"version": "v1.49.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-auth-library-php.git",
"reference": "68e3d88cb59a49f713e3db25d4f6bb3cc0b70764"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/68e3d88cb59a49f713e3db25d4f6bb3cc0b70764",
"reference": "68e3d88cb59a49f713e3db25d4f6bb3cc0b70764",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.4.5",
"php": "^8.1",
"psr/cache": "^2.0||^3.0",
"psr/http-message": "^1.1||^2.0",
"psr/log": "^3.0"
},
"require-dev": {
"guzzlehttp/promises": "^2.0",
"kelvinmo/simplejwt": "0.7.1",
"phpseclib/phpseclib": "^3.0.35",
"phpspec/prophecy-phpunit": "^2.1",
"phpunit/phpunit": "^9.6",
"sebastian/comparator": ">=1.2.3",
"squizlabs/php_codesniffer": "^4.0",
"symfony/filesystem": "^6.3||^7.3",
"symfony/process": "^6.0||^7.0",
"webmozart/assert": "^1.11"
},
"suggest": {
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\Auth\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google Auth Library for PHP",
"homepage": "https://github.com/google/google-auth-library-php",
"keywords": [
"Authentication",
"google",
"oauth2"
],
"support": {
"docs": "https://cloud.google.com/php/docs/reference/auth/latest",
"issues": "https://github.com/googleapis/google-auth-library-php/issues",
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.49.0"
},
"time": "2025-11-06T21:27:55+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.3",

78
config/fcm.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| FCM Project ID
|--------------------------------------------------------------------------
|
| Firebase 프로젝트 ID (Firebase Console에서 확인)
|
*/
'project_id' => env('FCM_PROJECT_ID'),
/*
|--------------------------------------------------------------------------
| Service Account JSON Path
|--------------------------------------------------------------------------
|
| Firebase Admin SDK 서비스 계정 JSON 파일 경로
| storage_path() 기준 상대 경로 또는 절대 경로
|
*/
'service_account_path' => env('FCM_SA_PATH', 'app/firebase-service-account.json'),
/*
|--------------------------------------------------------------------------
| FCM HTTP v1 Endpoint
|--------------------------------------------------------------------------
*/
'endpoint' => 'https://fcm.googleapis.com/v1/projects/{project_id}/messages:send',
/*
|--------------------------------------------------------------------------
| Android Notification Channels
|--------------------------------------------------------------------------
|
| 앱에서 정의된 알림 채널 ID 매핑
|
*/
'channels' => [
'default' => 'push_default',
'urgent' => 'push_urgent',
],
/*
|--------------------------------------------------------------------------
| Default Settings
|--------------------------------------------------------------------------
*/
'defaults' => [
'channel_id' => 'push_default',
'priority' => 'high',
'ttl' => '86400s', // 24시간
],
/*
|--------------------------------------------------------------------------
| Batch Settings
|--------------------------------------------------------------------------
|
| 대량 발송 시 rate limit 관리 설정
|
*/
'batch' => [
'chunk_size' => env('FCM_BATCH_CHUNK_SIZE', 200), // 한 번에 처리할 토큰 수
'delay_ms' => env('FCM_BATCH_DELAY_MS', 100), // chunk 간 딜레이 (ms)
],
/*
|--------------------------------------------------------------------------
| Logging
|--------------------------------------------------------------------------
*/
'logging' => [
'enabled' => env('FCM_LOGGING_ENABLED', true),
'channel' => env('FCM_LOG_CHANNEL', 'stack'),
],
];

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('fcm_send_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->nullable()->comment('테넌트 ID (전체 발송 시 null)');
$table->unsignedBigInteger('user_id')->nullable()->comment('대상 사용자 ID (전체 발송 시 null)');
$table->unsignedBigInteger('sender_id')->comment('발송자 (MNG 관리자) ID');
$table->string('title')->comment('알림 제목');
$table->text('body')->comment('알림 내용');
$table->string('channel_id', 50)->default('push_default')->comment('알림 채널');
$table->string('type', 50)->nullable()->comment('알림 타입');
$table->string('platform', 20)->nullable()->comment('플랫폼 필터 (android, ios, web)');
$table->json('data')->nullable()->comment('추가 데이터 (JSON)');
$table->unsignedInteger('total_count')->default(0)->comment('총 발송 수');
$table->unsignedInteger('success_count')->default(0)->comment('성공 수');
$table->unsignedInteger('failure_count')->default(0)->comment('실패 수');
$table->unsignedInteger('invalid_token_count')->default(0)->comment('무효 토큰 수');
$table->decimal('success_rate', 5, 2)->default(0)->comment('성공률 (%)');
$table->string('status', 20)->default('pending')->comment('상태 (pending, sending, completed, failed)');
$table->text('error_message')->nullable()->comment('에러 메시지');
$table->timestamp('completed_at')->nullable()->comment('완료 시간');
$table->timestamps();
$table->index('tenant_id');
$table->index('sender_id');
$table->index('status');
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('fcm_send_logs');
}
};

View File

@@ -0,0 +1,65 @@
@extends('layouts.app')
@section('title', 'FCM 발송 이력')
@section('content')
<div>
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">FCM 발송 이력</h1>
<div class="flex gap-2">
<a href="{{ route('fcm.tokens') }}" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition">
토큰 관리
</a>
<a href="{{ route('fcm.send') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
테스트 발송
</a>
</div>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form hx-get="{{ route('fcm.history.list') }}"
hx-target="#table-container"
hx-trigger="submit, change from:select, change from:input[type=date]"
hx-push-url="true">
<div class="flex flex-wrap gap-4">
<!-- 상태 필터 -->
<div class="w-36">
<select name="status" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 상태</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }}>대기</option>
<option value="sending" {{ request('status') === 'sending' ? 'selected' : '' }}>발송 </option>
<option value="completed" {{ request('status') === 'completed' ? 'selected' : '' }}>완료</option>
<option value="failed" {{ request('status') === 'failed' ? 'selected' : '' }}>실패</option>
</select>
</div>
<!-- 기간 필터 -->
<div class="flex gap-2 items-center">
<input type="date"
name="from"
value="{{ request('from') }}"
class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<span class="text-gray-500">~</span>
<input type="date"
name="to"
value="{{ request('to') }}"
class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</div>
</form>
</div>
<!-- 테이블 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div id="table-container">
@include('fcm.partials.history-table', ['logs' => $logs])
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,76 @@
<div id="history-table-container">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">발송자</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">대상</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">제목</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">결과</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">발송일시</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($logs as $log)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $log->id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ $log->sender?->name ?? '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $log->tenant?->company_name ?? '전체' }}
@if($log->platform)
<span class="ml-1 text-xs text-gray-400">({{ $log->platform }})</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 truncate max-w-[200px]" title="{{ $log->title }}">{{ $log->title }}</div>
<div class="text-sm text-gray-500 truncate max-w-[200px]" title="{{ $log->body }}">{{ $log->body }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm">
<span class="text-green-600">{{ $log->success_count }}/{{ $log->total_count }}</span>
<span class="text-gray-400">({{ $log->success_rate }}%)</span>
</div>
@if($log->invalid_token_count > 0)
<div class="text-xs text-red-500">무효: {{ $log->invalid_token_count }}</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full
@if($log->status === 'pending') bg-gray-100 text-gray-800
@elseif($log->status === 'sending') bg-blue-100 text-blue-800
@elseif($log->status === 'completed') bg-green-100 text-green-800
@else bg-red-100 text-red-800
@endif">
@switch($log->status)
@case('pending') 대기 @break
@case('sending') 발송 @break
@case('completed') 완료 @break
@case('failed') 실패 @break
@default {{ $log->status }}
@endswitch
</span>
@if($log->error_message)
<div class="text-xs text-red-500 mt-1">{{ $log->error_message }}</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $log->created_at?->format('Y-m-d H:i') ?? '-' }}
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
발송 이력이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
@if($logs->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $logs->withQueryString()->links() }}
</div>
@endif
</div>

View File

@@ -0,0 +1 @@
<span class="font-bold text-blue-600">{{ number_format($count) }}</span> 토큰

View File

@@ -0,0 +1,40 @@
<div class="rounded-lg p-4 {{ $success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200' }}">
<div class="flex items-center">
@if($success)
<svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
@else
<svg class="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
@endif
<span class="font-medium {{ $success ? 'text-green-800' : 'text-red-800' }}">{{ $message }}</span>
</div>
@if($success && isset($data))
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="bg-white rounded p-3">
<div class="text-xs text-gray-500">전체</div>
<div class="text-lg font-bold">{{ $data['total'] }}</div>
</div>
<div class="bg-white rounded p-3">
<div class="text-xs text-gray-500">성공</div>
<div class="text-lg font-bold text-green-600">{{ $data['success'] }}</div>
</div>
<div class="bg-white rounded p-3">
<div class="text-xs text-gray-500">실패</div>
<div class="text-lg font-bold text-red-600">{{ $data['failure'] }}</div>
</div>
<div class="bg-white rounded p-3">
<div class="text-xs text-gray-500">성공률</div>
<div class="text-lg font-bold">{{ $data['success_rate'] }}%</div>
</div>
</div>
@if($data['invalid_tokens'] > 0)
<div class="mt-2 text-sm text-orange-600">
무효 토큰 {{ $data['invalid_tokens'] }}개가 비활성화되었습니다.
</div>
@endif
@endif
</div>

View File

@@ -0,0 +1,50 @@
<tr id="token-row-{{ $token->id }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $token->user?->name ?? '-' }}</div>
<div class="text-sm text-gray-500">{{ $token->user?->email ?? '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $token->tenant?->company_name ?? '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full
@if($token->platform === 'android') bg-green-100 text-green-800
@elseif($token->platform === 'ios') bg-blue-100 text-blue-800
@else bg-purple-100 text-purple-800
@endif">
{{ $token->platform }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $token->device_name ?? '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-xs text-gray-500 font-mono truncate max-w-[150px]" title="{{ $token->token }}">
{{ Str::limit($token->token, 30) }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($token->is_active)
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">활성</span>
@else
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800">비활성</span>
@endif
@if($token->last_error)
<div class="text-xs text-red-500 mt-1">{{ $token->last_error }}</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $token->created_at?->format('Y-m-d H:i') ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<button hx-post="{{ route('fcm.tokens.toggle', $token->id) }}"
hx-target="#token-row-{{ $token->id }}"
hx-swap="outerHTML"
class="text-blue-600 hover:text-blue-900 mr-3">
{{ $token->is_active ? '비활성화' : '활성화' }}
</button>
<button hx-delete="{{ route('fcm.tokens.delete', $token->id) }}"
hx-target="#token-row-{{ $token->id }}"
hx-swap="outerHTML swap:1s"
hx-confirm="토큰을 삭제하시겠습니까?"
class="text-red-600 hover:text-red-900">
삭제
</button>
</td>
</tr>

View File

@@ -0,0 +1,26 @@
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체 토큰</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">활성</div>
<div class="text-2xl font-bold text-green-600">{{ $stats['active'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">비활성</div>
<div class="text-2xl font-bold text-gray-400">{{ $stats['inactive'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">에러</div>
<div class="text-2xl font-bold text-red-600">{{ $stats['has_error'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">플랫폼</div>
<div class="text-sm">
<span class="text-green-600">Android: {{ $stats['by_platform']['android'] ?? 0 }}</span>
<span class="ml-2 text-blue-600">iOS: {{ $stats['by_platform']['ios'] ?? 0 }}</span>
<span class="ml-2 text-purple-600">Web: {{ $stats['by_platform']['web'] ?? 0 }}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
<div id="token-table-container">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">사용자</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">테넌트</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">플랫폼</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">기기명</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">토큰</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">등록일</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($tokens as $token)
@include('fcm.partials.token-row', ['token' => $token])
@empty
<tr>
<td colspan="8" class="px-6 py-12 text-center text-gray-500">
등록된 토큰이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
@if($tokens->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $tokens->withQueryString()->links() }}
</div>
@endif
</div>

View File

@@ -0,0 +1,167 @@
@extends('layouts.app')
@section('title', 'FCM 테스트 발송')
@section('content')
<div>
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">FCM 테스트 발송</h1>
<div class="flex gap-2">
<a href="{{ route('fcm.tokens') }}" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition">
토큰 관리
</a>
<a href="{{ route('fcm.history') }}" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition">
발송 이력
</a>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 발송 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">발송 설정</h2>
<form id="send-form"
hx-post="{{ route('fcm.send.push') }}"
hx-target="#send-result"
hx-indicator="#send-indicator">
@csrf
<!-- 대상 선택 -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트</label>
<select name="tenant_id"
hx-get="{{ route('fcm.preview-count') }}"
hx-target="#preview-count"
hx-trigger="change"
hx-include="#send-form"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체</option>
@foreach($tenants as $tenant)
<option value="{{ $tenant->id }}">{{ $tenant->company_name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">플랫폼</label>
<select name="platform"
hx-get="{{ route('fcm.preview-count') }}"
hx-target="#preview-count"
hx-trigger="change"
hx-include="#send-form"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체</option>
<option value="android">Android</option>
<option value="ios">iOS</option>
<option value="web">Web</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 ID (선택)</label>
<input type="number"
name="user_id"
hx-get="{{ route('fcm.preview-count') }}"
hx-target="#preview-count"
hx-trigger="change"
hx-include="#send-form"
placeholder="특정 사용자에게만 발송"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<hr class="my-6">
<!-- 메시지 설정 -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">제목 *</label>
<input type="text"
name="title"
required
placeholder="알림 제목"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">내용 *</label>
<textarea name="body"
required
rows="3"
placeholder="알림 내용"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
<hr class="my-6">
<!-- 고급 설정 -->
<details class="mb-4">
<summary class="cursor-pointer text-sm font-medium text-gray-700">고급 설정</summary>
<div class="mt-4 space-y-4 pl-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">채널 ID</label>
<input type="text"
name="channel_id"
value="push_default"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">알림 타입</label>
<input type="text"
name="type"
placeholder="예: notice, event"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">딥링크 URL</label>
<input type="text"
name="url"
placeholder="예: /notices/123"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사운드 </label>
<input type="text"
name="sound_key"
placeholder="예: default, alarm"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</details>
<!-- 발송 버튼 -->
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500">
대상: <span id="preview-count" hx-get="{{ route('fcm.preview-count') }}" hx-trigger="load">
<span class="htmx-indicator" id="count-indicator">계산 ...</span>
</span>
</div>
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition flex items-center gap-2">
<span id="send-indicator" class="htmx-indicator">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
발송
</button>
</div>
</form>
</div>
<!-- 발송 결과 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">발송 결과</h2>
<div id="send-result">
<div class="text-center text-gray-500 py-12">
발송 버튼을 클릭하면 결과가 여기에 표시됩니다.
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,97 @@
@extends('layouts.app')
@section('title', 'FCM 토큰 관리')
@section('content')
<div>
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">FCM 토큰 관리</h1>
<div class="flex gap-2">
<a href="{{ route('fcm.send') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
테스트 발송
</a>
<a href="{{ route('fcm.history') }}" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition">
발송 이력
</a>
</div>
</div>
<!-- 통계 카드 -->
<div id="stats-container"
hx-get="{{ route('fcm.tokens.stats') }}"
hx-trigger="tokenDeleted from:body">
@include('fcm.partials.token-stats', ['stats' => $stats])
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filter-form"
hx-get="{{ route('fcm.tokens.list') }}"
hx-target="#table-container"
hx-trigger="submit, change from:select"
hx-push-url="true">
<div class="flex flex-wrap gap-4">
<!-- 테넌트 필터 -->
<div class="w-48">
<select name="tenant_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 테넌트</option>
@foreach($tenants as $tenant)
<option value="{{ $tenant->id }}" {{ request('tenant_id') == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
</select>
</div>
<!-- 플랫폼 필터 -->
<div class="w-36">
<select name="platform" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 플랫폼</option>
<option value="android" {{ request('platform') === 'android' ? 'selected' : '' }}>Android</option>
<option value="ios" {{ request('platform') === 'ios' ? 'selected' : '' }}>iOS</option>
<option value="web" {{ request('platform') === 'web' ? 'selected' : '' }}>Web</option>
</select>
</div>
<!-- 상태 필터 -->
<div class="w-36">
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 상태</option>
<option value="1" {{ request('is_active') === '1' ? 'selected' : '' }}>활성</option>
<option value="0" {{ request('is_active') === '0' ? 'selected' : '' }}>비활성</option>
</select>
</div>
<!-- 에러 필터 -->
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="has_error" value="1" {{ request('has_error') ? 'checked' : '' }} class="mr-2">
<span class="text-sm text-gray-700">에러 토큰만</span>
</label>
</div>
<!-- 검색 -->
<div class="flex-1 min-w-[200px]">
<input type="text"
name="search"
value="{{ request('search') }}"
placeholder="토큰, 기기명, 사용자 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</div>
</form>
</div>
<!-- 테이블 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div id="table-container">
@include('fcm.partials.token-table', ['tokens' => $tokens])
</div>
</div>
</div>
@endsection

View File

@@ -8,6 +8,7 @@
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\DevTools\ApiExplorerController;
use App\Http\Controllers\DevTools\FlowTesterController;
use App\Http\Controllers\FcmController;
use App\Http\Controllers\ItemFieldController;
use App\Http\Controllers\Lab\AIController;
use App\Http\Controllers\Lab\ManagementController;
@@ -260,6 +261,29 @@
});
});
/*
|--------------------------------------------------------------------------
| FCM 관리 Routes
|--------------------------------------------------------------------------
*/
Route::prefix('fcm')->name('fcm.')->group(function () {
// 토큰 관리
Route::get('/tokens', [FcmController::class, 'tokens'])->name('tokens');
Route::get('/tokens/list', [FcmController::class, 'tokenList'])->name('tokens.list');
Route::get('/tokens/stats', [FcmController::class, 'tokenStats'])->name('tokens.stats');
Route::post('/tokens/{id}/toggle', [FcmController::class, 'toggleToken'])->name('tokens.toggle');
Route::delete('/tokens/{id}', [FcmController::class, 'deleteToken'])->name('tokens.delete');
// 테스트 발송
Route::get('/send', [FcmController::class, 'send'])->name('send');
Route::post('/send', [FcmController::class, 'sendPush'])->name('send.push');
Route::get('/preview-count', [FcmController::class, 'previewCount'])->name('preview-count');
// 발송 이력
Route::get('/history', [FcmController::class, 'history'])->name('history');
Route::get('/history/list', [FcmController::class, 'historyList'])->name('history.list');
});
/*
|--------------------------------------------------------------------------
| 개발 도구 Routes