feat: [fcm] Admin FCM API 추가 - MNG에서 API 호출로 FCM 발송
- AdminFcmController, AdminFcmService 추가 - FormRequest 검증 (AdminFcmSendRequest 등) - Swagger 문서 추가 (AdminFcmApi.php) - ApiKeyMiddleware: admin/fcm/* 화이트리스트 추가 - FCM 에러 메시지 i18n 추가
This commit is contained in:
117
app/Http/Controllers/Api/V1/Admin/FcmController.php
Normal file
117
app/Http/Controllers/Api/V1/Admin/FcmController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\FcmHistoryRequest;
|
||||
use App\Http\Requests\Admin\FcmTokenListRequest;
|
||||
use App\Http\Requests\Admin\SendFcmRequest;
|
||||
use App\Services\AdminFcmService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FcmController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminFcmService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* FCM 발송
|
||||
*/
|
||||
public function send(SendFcmRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
// sender_id는 MNG 관리자 ID로, 헤더에서 전달받음
|
||||
$senderId = $request->header('X-Sender-Id');
|
||||
|
||||
$result = $this->service->send(
|
||||
$request->validated(),
|
||||
$senderId ? (int) $senderId : null
|
||||
);
|
||||
|
||||
if (! $result['success']) {
|
||||
return ApiResponse::error($result['message'], 422, $result);
|
||||
}
|
||||
|
||||
return $result['data'];
|
||||
}, __('message.fcm.sent'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 토큰 수 미리보기
|
||||
*/
|
||||
public function previewCount(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$count = $this->service->previewCount($request->only([
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'platform',
|
||||
]));
|
||||
|
||||
return ['count' => $count];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 목록 조회
|
||||
*/
|
||||
public function tokens(FcmTokenListRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->getTokens(
|
||||
$request->validated(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 통계
|
||||
*/
|
||||
public function tokenStats(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->getTokenStats(
|
||||
$request->integer('tenant_id')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 상태 토글
|
||||
*/
|
||||
public function toggleToken(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->toggleToken($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 삭제
|
||||
*/
|
||||
public function deleteToken(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->deleteToken($id);
|
||||
|
||||
return ['deleted' => true];
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 조회
|
||||
*/
|
||||
public function history(FcmHistoryRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->getHistory(
|
||||
$request->validated(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,13 +122,22 @@ public function handle(Request $request, Closure $next)
|
||||
'api/v1/refresh',
|
||||
'api/v1/debug-apikey',
|
||||
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
|
||||
// 추가적으로 허용하고 싶은 라우트
|
||||
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
|
||||
];
|
||||
|
||||
// 현재 라우트 확인 (경로 또는 이름)
|
||||
$currentRoute = $request->route()?->uri() ?? $request->path();
|
||||
|
||||
if (! in_array($currentRoute, $allowWithoutAuth)) {
|
||||
// 화이트리스트 패턴 매칭 (와일드카드 지원)
|
||||
$isAllowedWithoutAuth = false;
|
||||
foreach ($allowWithoutAuth as $pattern) {
|
||||
if ($pattern === $currentRoute || fnmatch($pattern, $currentRoute)) {
|
||||
$isAllowedWithoutAuth = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isAllowedWithoutAuth) {
|
||||
// 인증정보(api_user, tenant_id) 없으면 튕김
|
||||
if (! app()->bound('api_user')) {
|
||||
throw new AuthenticationException('회원정보 정보 없음');
|
||||
|
||||
24
app/Http/Requests/Admin/FcmHistoryRequest.php
Normal file
24
app/Http/Requests/Admin/FcmHistoryRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class FcmHistoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'nullable|integer|exists:tenants,id',
|
||||
'status' => 'nullable|string|in:pending,sending,completed,failed',
|
||||
'from' => 'nullable|date',
|
||||
'to' => 'nullable|date|after_or_equal:from',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/Admin/FcmTokenListRequest.php
Normal file
25
app/Http/Requests/Admin/FcmTokenListRequest.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class FcmTokenListRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'nullable|integer|exists:tenants,id',
|
||||
'platform' => 'nullable|string|in:android,ios,web',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'has_error' => 'nullable|boolean',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/Admin/SendFcmRequest.php
Normal file
37
app/Http/Requests/Admin/SendFcmRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SendFcmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:255',
|
||||
'body' => 'required|string|max:1000',
|
||||
'tenant_id' => 'nullable|integer|exists:tenants,id',
|
||||
'user_id' => 'nullable|integer|exists:users,id',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => __('validation.required', ['attribute' => '제목']),
|
||||
'body.required' => __('validation.required', ['attribute' => '내용']),
|
||||
'platform.in' => __('validation.in', ['attribute' => '플랫폼']),
|
||||
];
|
||||
}
|
||||
}
|
||||
134
app/Models/FcmSendLog.php
Normal file
134
app/Models/FcmSendLog.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
264
app/Services/AdminFcmService.php
Normal file
264
app/Services/AdminFcmService.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
562
app/Swagger/v1/AdminFcmApi.php
Normal file
562
app/Swagger/v1/AdminFcmApi.php
Normal file
@@ -0,0 +1,562 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Admin FCM", description="관리자 FCM 푸시 알림 관리 (MNG 전용)")
|
||||
*/
|
||||
|
||||
/**
|
||||
* Admin FCM 관련 스키마 정의
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmToken",
|
||||
* type="object",
|
||||
* description="FCM 디바이스 토큰",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=5),
|
||||
* @OA\Property(property="token", type="string", example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4..."),
|
||||
* @OA\Property(property="platform", type="string", enum={"ios", "android", "web"}, example="android"),
|
||||
* @OA\Property(property="device_name", type="string", nullable=true, example="Samsung Galaxy S24"),
|
||||
* @OA\Property(property="app_version", type="string", nullable=true, example="1.0.0"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="has_error", type="boolean", example=false),
|
||||
* @OA\Property(property="error_message", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="last_used_at", type="string", format="date-time", nullable=true, example="2025-12-18 10:30:00"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18 10:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18 10:30:00"),
|
||||
* @OA\Property(
|
||||
* property="user",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* @OA\Property(property="id", type="integer", example=5),
|
||||
* @OA\Property(property="name", type="string", example="홍길동"),
|
||||
* @OA\Property(property="email", type="string", example="hong@example.com")
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="tenant",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="테스트 회사")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmSendLog",
|
||||
* type="object",
|
||||
* description="FCM 발송 이력",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="sender_id", type="integer", nullable=true, example=10, description="발송자 ID (MNG 관리자)"),
|
||||
* @OA\Property(property="title", type="string", example="공지사항"),
|
||||
* @OA\Property(property="body", type="string", example="새로운 공지가 등록되었습니다."),
|
||||
* @OA\Property(property="channel_id", type="string", nullable=true, example="notice"),
|
||||
* @OA\Property(property="type", type="string", nullable=true, example="notice"),
|
||||
* @OA\Property(property="url", type="string", nullable=true, example="/notices/123"),
|
||||
* @OA\Property(property="total_tokens", type="integer", example=150),
|
||||
* @OA\Property(property="success_count", type="integer", example=148),
|
||||
* @OA\Property(property="failure_count", type="integer", example=2),
|
||||
* @OA\Property(property="status", type="string", enum={"pending", "sending", "completed", "failed"}, example="completed"),
|
||||
* @OA\Property(property="error_message", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18 10:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18 10:01:00"),
|
||||
* @OA\Property(
|
||||
* property="tenant",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="테스트 회사")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmSendRequest",
|
||||
* type="object",
|
||||
* required={"title", "body"},
|
||||
*
|
||||
* @OA\Property(property="title", type="string", maxLength=100, example="공지사항", description="푸시 제목"),
|
||||
* @OA\Property(property="body", type="string", maxLength=500, example="새로운 공지가 등록되었습니다.", description="푸시 내용"),
|
||||
* @OA\Property(property="tenant_id", type="integer", nullable=true, example=1, description="특정 테넌트만 대상 (미지정시 전체)"),
|
||||
* @OA\Property(property="user_id", type="integer", nullable=true, example=5, description="특정 사용자만 대상"),
|
||||
* @OA\Property(property="platform", type="string", nullable=true, enum={"ios", "android", "web"}, example="android", description="특정 플랫폼만 대상"),
|
||||
* @OA\Property(property="channel_id", type="string", nullable=true, maxLength=50, example="notice", description="알림 채널 ID"),
|
||||
* @OA\Property(property="type", type="string", nullable=true, maxLength=50, example="notice", description="알림 유형"),
|
||||
* @OA\Property(property="url", type="string", nullable=true, maxLength=255, example="/notices/123", description="클릭 시 이동할 URL"),
|
||||
* @OA\Property(property="sound_key", type="string", nullable=true, maxLength=50, example="default", description="알림음 키")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmTokenListRequest",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="tenant_id", type="integer", nullable=true, example=1, description="테넌트 ID로 필터"),
|
||||
* @OA\Property(property="platform", type="string", nullable=true, enum={"ios", "android", "web"}, example="android", description="플랫폼으로 필터"),
|
||||
* @OA\Property(property="is_active", type="boolean", nullable=true, example=true, description="활성 상태로 필터"),
|
||||
* @OA\Property(property="has_error", type="boolean", nullable=true, example=false, description="에러 여부로 필터"),
|
||||
* @OA\Property(property="search", type="string", nullable=true, maxLength=100, example="홍길동", description="사용자명/이메일 검색"),
|
||||
* @OA\Property(property="per_page", type="integer", minimum=1, maximum=100, example=20, description="페이지당 항목 수")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmHistoryRequest",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="tenant_id", type="integer", nullable=true, example=1, description="테넌트 ID로 필터"),
|
||||
* @OA\Property(property="status", type="string", nullable=true, enum={"pending", "sending", "completed", "failed"}, example="completed", description="발송 상태로 필터"),
|
||||
* @OA\Property(property="from", type="string", format="date", nullable=true, example="2025-12-01", description="시작일"),
|
||||
* @OA\Property(property="to", type="string", format="date", nullable=true, example="2025-12-31", description="종료일"),
|
||||
* @OA\Property(property="per_page", type="integer", minimum=1, maximum=100, example=20, description="페이지당 항목 수")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmTokenStats",
|
||||
* type="object",
|
||||
* description="토큰 통계",
|
||||
*
|
||||
* @OA\Property(property="total", type="integer", example=500, description="전체 토큰 수"),
|
||||
* @OA\Property(property="active", type="integer", example=450, description="활성 토큰 수"),
|
||||
* @OA\Property(property="inactive", type="integer", example=50, description="비활성 토큰 수"),
|
||||
* @OA\Property(property="has_error", type="integer", example=10, description="에러 발생 토큰 수"),
|
||||
* @OA\Property(
|
||||
* property="by_platform",
|
||||
* type="object",
|
||||
* @OA\Property(property="android", type="integer", example=300),
|
||||
* @OA\Property(property="ios", type="integer", example=180),
|
||||
* @OA\Property(property="web", type="integer", example=20)
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmTokenPagination",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=150),
|
||||
* @OA\Property(property="last_page", type="integer", example=8),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/AdminFcmToken")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AdminFcmHistoryPagination",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=50),
|
||||
* @OA\Property(property="last_page", type="integer", example=3),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/AdminFcmSendLog")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class AdminFcmApi
|
||||
{
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/admin/fcm/send",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="FCM 푸시 발송",
|
||||
* description="대상 토큰에 FCM 푸시 알림을 발송합니다. 테넌트, 사용자, 플랫폼으로 대상을 필터링할 수 있습니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Header(
|
||||
* header="X-Sender-Id",
|
||||
* description="발송자 ID (MNG 관리자 ID)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", example=10)
|
||||
* ),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/AdminFcmSendRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="발송 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="message", type="string", example="FCM 발송이 완료되었습니다."),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="log_id", type="integer", example=1),
|
||||
* @OA\Property(property="total_tokens", type="integer", example=150),
|
||||
* @OA\Property(property="success_count", type="integer", example=148),
|
||||
* @OA\Property(property="failure_count", type="integer", example=2)
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="발송 대상 없음 또는 검증 실패",
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/ErrorResponse")
|
||||
* ),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function send() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/admin/fcm/preview-count",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="대상 토큰 수 미리보기",
|
||||
* description="발송 전 필터 조건에 맞는 활성 토큰 수를 미리 확인합니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="tenant_id",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="테넌트 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="user_id",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="사용자 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=5)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="platform",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="플랫폼",
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"ios", "android", "web"}, example="android")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="count", type="integer", example=150)
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function previewCount() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/admin/fcm/tokens",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="토큰 목록 조회",
|
||||
* description="등록된 FCM 토큰 목록을 조회합니다. 테넌트, 플랫폼, 활성 상태, 에러 여부로 필터링할 수 있습니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="tenant_id",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="테넌트 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="platform",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="플랫폼",
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"ios", "android", "web"}, example="android")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="is_active",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="활성 상태",
|
||||
*
|
||||
* @OA\Schema(type="boolean", example=true)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="has_error",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="에러 여부",
|
||||
*
|
||||
* @OA\Schema(type="boolean", example=false)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="search",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="사용자명/이메일 검색",
|
||||
*
|
||||
* @OA\Schema(type="string", example="홍길동")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="per_page",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="페이지당 항목 수",
|
||||
*
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, example=20)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/AdminFcmTokenPagination")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function tokens() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/admin/fcm/tokens/stats",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="토큰 통계 조회",
|
||||
* description="FCM 토큰의 전체 통계를 조회합니다. 활성/비활성, 플랫폼별 분포 등을 확인할 수 있습니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="tenant_id",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="테넌트 ID (미지정시 전체)",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/AdminFcmTokenStats")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function tokenStats() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/admin/fcm/tokens/{id}/toggle",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="토큰 상태 토글",
|
||||
* description="토큰의 활성/비활성 상태를 토글합니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* required=true,
|
||||
* description="토큰 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="상태 변경 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="message", type="string", example="토큰 상태가 변경되었습니다."),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/AdminFcmToken")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="토큰을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function toggleToken() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/admin/fcm/tokens/{id}",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="토큰 삭제",
|
||||
* description="FCM 토큰을 삭제합니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* required=true,
|
||||
* description="토큰 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="삭제 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="message", type="string", example="삭제되었습니다."),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="deleted", type="boolean", example=true)
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="토큰을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function deleteToken() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/admin/fcm/history",
|
||||
* tags={"Admin FCM"},
|
||||
* summary="발송 이력 조회",
|
||||
* description="FCM 발송 이력을 조회합니다. 테넌트, 상태, 기간으로 필터링할 수 있습니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="tenant_id",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="테넌트 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="status",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="발송 상태",
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"pending", "sending", "completed", "failed"}, example="completed")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="from",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="시작일",
|
||||
*
|
||||
* @OA\Schema(type="string", format="date", example="2025-12-01")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="to",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="종료일",
|
||||
*
|
||||
* @OA\Schema(type="string", format="date", example="2025-12-31")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="per_page",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="페이지당 항목 수",
|
||||
*
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, example=20)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/AdminFcmHistoryPagination")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function history() {}
|
||||
}
|
||||
@@ -321,4 +321,11 @@
|
||||
'tenant_creation_failed' => '테넌트 생성에 실패했습니다.',
|
||||
'invalid_business_number' => '유효하지 않은 사업자등록번호입니다.',
|
||||
],
|
||||
|
||||
// FCM 푸시 알림 관련
|
||||
'fcm' => [
|
||||
'no_tokens' => '발송 대상 토큰이 없습니다.',
|
||||
'send_failed' => 'FCM 발송 중 오류가 발생했습니다.',
|
||||
'token_not_found' => 'FCM 토큰을 찾을 수 없습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -647,6 +647,17 @@
|
||||
Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); // 알림 유형 목록
|
||||
});
|
||||
|
||||
// Admin FCM API (MNG 관리자용 FCM 발송) - API Key 인증만 사용
|
||||
Route::prefix('admin/fcm')->group(function () {
|
||||
Route::post('/send', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'send'])->name('v1.admin.fcm.send'); // FCM 발송
|
||||
Route::get('/preview-count', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'previewCount'])->name('v1.admin.fcm.preview'); // 대상 토큰 수 미리보기
|
||||
Route::get('/tokens', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'tokens'])->name('v1.admin.fcm.tokens'); // 토큰 목록
|
||||
Route::get('/tokens/stats', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'tokenStats'])->name('v1.admin.fcm.tokens.stats'); // 토큰 통계
|
||||
Route::patch('/tokens/{id}/toggle', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'toggleToken'])->name('v1.admin.fcm.tokens.toggle'); // 토큰 상태 토글
|
||||
Route::delete('/tokens/{id}', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'deleteToken'])->name('v1.admin.fcm.tokens.delete'); // 토큰 삭제
|
||||
Route::get('/history', [\App\Http\Controllers\Api\V1\Admin\FcmController::class, 'history'])->name('v1.admin.fcm.history'); // 발송 이력
|
||||
});
|
||||
|
||||
// 회원 프로필(테넌트 기준)
|
||||
Route::prefix('profiles')->group(function () {
|
||||
Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준)
|
||||
|
||||
Reference in New Issue
Block a user