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:
2025-12-23 12:45:28 +09:00
parent c8ad3c908c
commit 75be618f98
10 changed files with 1192 additions and 2 deletions

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

View File

@@ -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('회원정보 정보 없음');

View 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',
];
}
}

View 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',
];
}
}

View 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
View 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(),
]);
}
}

View 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();
}
}

View 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() {}
}

View File

@@ -321,4 +321,11 @@
'tenant_creation_failed' => '테넌트 생성에 실패했습니다.',
'invalid_business_number' => '유효하지 않은 사업자등록번호입니다.',
],
// FCM 푸시 알림 관련
'fcm' => [
'no_tokens' => '발송 대상 토큰이 없습니다.',
'send_failed' => 'FCM 발송 중 오류가 발생했습니다.',
'token_not_found' => 'FCM 토큰을 찾을 수 없습니다.',
],
];

View File

@@ -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'); // 프로필 목록(테넌트 기준)