feat: FCM 관리자 페이지 추가
- FCM 토큰 관리 페이지 (목록, 활성화/비활성화, 삭제) - 테스트 발송 페이지 (대상 필터, 미리보기, 발송) - 발송 이력 페이지 (필터링, 결과 확인) - FcmSender 서비스 (HTTP v1, 배치 처리) - fcm_send_logs 테이블 마이그레이션 - google/auth 패키지 추가
This commit is contained in:
@@ -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
|
||||
|
||||
337
app/Http/Controllers/FcmController.php
Normal file
337
app/Http/Controllers/FcmController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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'; // 완전 삭제
|
||||
|
||||
/**
|
||||
@@ -109,4 +112,4 @@ public function scopeActive($query)
|
||||
self::STATUS_DEPRECATED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
135
app/Models/FcmSendLog.php
Normal file
135
app/Models/FcmSendLog.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
125
app/Models/PushDeviceToken.php
Normal file
125
app/Models/PushDeviceToken.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -254,4 +254,4 @@ public function getDailyTrend(int $days = 30): Collection
|
||||
->orderBy('date')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
app/Services/Fcm/FcmBatchResult.php
Normal file
112
app/Services/Fcm/FcmBatchResult.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Services/Fcm/FcmException.php
Normal file
27
app/Services/Fcm/FcmException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
86
app/Services/Fcm/FcmResponse.php
Normal file
86
app/Services/Fcm/FcmResponse.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
273
app/Services/Fcm/FcmSender.php
Normal file
273
app/Services/Fcm/FcmSender.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
127
composer.lock
generated
@@ -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
78
config/fcm.php
Normal 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'),
|
||||
],
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
65
resources/views/fcm/history.blade.php
Normal file
65
resources/views/fcm/history.blade.php
Normal 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
|
||||
76
resources/views/fcm/partials/history-table.blade.php
Normal file
76
resources/views/fcm/partials/history-table.blade.php
Normal 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>
|
||||
1
resources/views/fcm/partials/preview-count.blade.php
Normal file
1
resources/views/fcm/partials/preview-count.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
<span class="font-bold text-blue-600">{{ number_format($count) }}</span>개 토큰
|
||||
40
resources/views/fcm/partials/send-result.blade.php
Normal file
40
resources/views/fcm/partials/send-result.blade.php
Normal 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>
|
||||
50
resources/views/fcm/partials/token-row.blade.php
Normal file
50
resources/views/fcm/partials/token-row.blade.php
Normal 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>
|
||||
26
resources/views/fcm/partials/token-stats.blade.php
Normal file
26
resources/views/fcm/partials/token-stats.blade.php
Normal 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>
|
||||
33
resources/views/fcm/partials/token-table.blade.php
Normal file
33
resources/views/fcm/partials/token-table.blade.php
Normal 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>
|
||||
167
resources/views/fcm/send.blade.php
Normal file
167
resources/views/fcm/send.blade.php
Normal 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
|
||||
97
resources/views/fcm/tokens.blade.php
Normal file
97
resources/views/fcm/tokens.blade.php
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user