2025-12-23 12:45:28 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Models\FcmSendLog;
|
|
|
|
|
use App\Models\PushDeviceToken;
|
|
|
|
|
use App\Services\Fcm\FcmSender;
|
|
|
|
|
|
|
|
|
|
class AdminFcmService extends Service
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly FcmSender $fcmSender
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 관리자 FCM 발송
|
|
|
|
|
*
|
|
|
|
|
* @param array $data 발송 데이터
|
|
|
|
|
* @param int|null $senderId 발송자 ID (MNG 관리자)
|
|
|
|
|
*/
|
|
|
|
|
public function send(array $data, ?int $senderId = null): array
|
|
|
|
|
{
|
|
|
|
|
// 대상 토큰 조회
|
|
|
|
|
$query = PushDeviceToken::withoutGlobalScopes()->active();
|
|
|
|
|
|
|
|
|
|
if (! empty($data['tenant_id'])) {
|
|
|
|
|
$query->forTenant($data['tenant_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($data['user_id'])) {
|
|
|
|
|
$query->forUser($data['user_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($data['platform'])) {
|
|
|
|
|
$query->platform($data['platform']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tokens = $query->pluck('token')->toArray();
|
|
|
|
|
$tokenCount = count($tokens);
|
|
|
|
|
|
|
|
|
|
if ($tokenCount === 0) {
|
|
|
|
|
return [
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => __('error.fcm.no_tokens'),
|
|
|
|
|
'data' => null,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 발송 로그 생성
|
|
|
|
|
$sendLog = FcmSendLog::create([
|
|
|
|
|
'tenant_id' => $data['tenant_id'] ?? null,
|
|
|
|
|
'user_id' => $data['user_id'] ?? null,
|
|
|
|
|
'sender_id' => $senderId,
|
|
|
|
|
'title' => $data['title'],
|
|
|
|
|
'body' => $data['body'],
|
|
|
|
|
'channel_id' => $data['channel_id'] ?? 'push_default',
|
|
|
|
|
'type' => $data['type'] ?? null,
|
|
|
|
|
'platform' => $data['platform'] ?? null,
|
|
|
|
|
'data' => array_filter([
|
|
|
|
|
'type' => $data['type'] ?? null,
|
|
|
|
|
'url' => $data['url'] ?? null,
|
|
|
|
|
'sound_key' => $data['sound_key'] ?? null,
|
|
|
|
|
]),
|
|
|
|
|
'status' => FcmSendLog::STATUS_SENDING,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// FCM 발송
|
|
|
|
|
$fcmData = array_filter([
|
|
|
|
|
'type' => $data['type'] ?? null,
|
|
|
|
|
'url' => $data['url'] ?? null,
|
|
|
|
|
'sound_key' => $data['sound_key'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->fcmSender->sendToMany(
|
|
|
|
|
$tokens,
|
|
|
|
|
$data['title'],
|
|
|
|
|
$data['body'],
|
|
|
|
|
$data['channel_id'] ?? 'push_default',
|
|
|
|
|
$fcmData
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 무효 토큰 비활성화
|
|
|
|
|
$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 [
|
|
|
|
|
'success' => true,
|
|
|
|
|
'message' => __('message.fcm.sent'),
|
|
|
|
|
'data' => $summary,
|
|
|
|
|
'log_id' => $sendLog->id,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
$sendLog->markAsFailed($e->getMessage());
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'success' => false,
|
|
|
|
|
'message' => __('error.fcm.send_failed'),
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
'log_id' => $sendLog->id,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 대상 토큰 수 미리보기
|
|
|
|
|
*/
|
|
|
|
|
public function previewCount(array $filters): int
|
|
|
|
|
{
|
|
|
|
|
$query = PushDeviceToken::withoutGlobalScopes()->active();
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['tenant_id'])) {
|
|
|
|
|
$query->forTenant($filters['tenant_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['user_id'])) {
|
|
|
|
|
$query->forUser($filters['user_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['platform'])) {
|
|
|
|
|
$query->platform($filters['platform']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $query->count();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 토큰 목록 조회 (관리자용)
|
|
|
|
|
*/
|
|
|
|
|
public function getTokens(array $filters, int $perPage = 20): array
|
|
|
|
|
{
|
|
|
|
|
$query = PushDeviceToken::withoutGlobalScopes()
|
|
|
|
|
->with(['user:id,name,email', 'tenant:id,company_name']);
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['tenant_id'])) {
|
|
|
|
|
$query->where('tenant_id', $filters['tenant_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['platform'])) {
|
|
|
|
|
$query->where('platform', $filters['platform']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isset($filters['is_active'])) {
|
|
|
|
|
$query->where('is_active', $filters['is_active']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['has_error'])) {
|
|
|
|
|
$query->hasError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['search'])) {
|
|
|
|
|
$search = $filters['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')
|
|
|
|
|
->paginate($perPage)
|
|
|
|
|
->toArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 토큰 통계
|
|
|
|
|
*/
|
|
|
|
|
public 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,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 토큰 상태 토글
|
|
|
|
|
*/
|
|
|
|
|
public function toggleToken(int $tokenId): PushDeviceToken
|
|
|
|
|
{
|
|
|
|
|
$token = PushDeviceToken::withoutGlobalScopes()->findOrFail($tokenId);
|
|
|
|
|
$token->update(['is_active' => ! $token->is_active]);
|
|
|
|
|
|
|
|
|
|
return $token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 토큰 삭제
|
|
|
|
|
*/
|
|
|
|
|
public function deleteToken(int $tokenId): bool
|
|
|
|
|
{
|
|
|
|
|
$token = PushDeviceToken::withoutGlobalScopes()->findOrFail($tokenId);
|
|
|
|
|
|
|
|
|
|
return $token->delete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 발송 이력 조회
|
|
|
|
|
*/
|
|
|
|
|
public function getHistory(array $filters, int $perPage = 20): array
|
|
|
|
|
{
|
|
|
|
|
$query = FcmSendLog::with(['sender:id,name', 'tenant:id,company_name']);
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['tenant_id'])) {
|
|
|
|
|
$query->where('tenant_id', $filters['tenant_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['status'])) {
|
|
|
|
|
$query->where('status', $filters['status']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['from'])) {
|
|
|
|
|
$query->whereDate('created_at', '>=', $filters['from']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($filters['to'])) {
|
|
|
|
|
$query->whereDate('created_at', '<=', $filters['to']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $query->orderBy('created_at', 'desc')
|
|
|
|
|
->paginate($perPage)
|
|
|
|
|
->toArray();
|
|
|
|
|
}
|
2026-01-13 19:49:06 +09:00
|
|
|
}
|