Laravel 13 提供了强大的推送通知系统,本文介绍如何集成和实现多平台推送通知。

推送通知概述

通知配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

return [
'default' => env('NOTIFICATION_CHANNEL', 'database'),

'channels' => [
'database' => [
'driver' => 'database',
'table' => 'notifications',
],

'fcm' => [
'driver' => 'fcm',
'project_id' => env('FCM_PROJECT_ID'),
'credentials' => storage_path('app/firebase-credentials.json'),
],

'apns' => [
'driver' => 'apns',
'certificate' => storage_path('app/aps.pem'),
'passphrase' => env('APNS_PASSPHRASE'),
'environment' => env('APNS_ENVIRONMENT', 'production'),
],

'webpush' => [
'driver' => 'webpush',
'vapid' => [
'subject' => env('VAPID_SUBJECT'),
'public_key' => env('VAPID_PUBLIC_KEY'),
'private_key' => env('VAPID_PRIVATE_KEY'),
],
],
],
];

设备令牌管理

设备令牌模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class DeviceToken extends Model
{
protected $fillable = [
'user_id',
'token',
'platform',
'device_name',
'device_id',
'is_active',
'last_used_at',
];

protected $casts = [
'is_active' => 'boolean',
'last_used_at' => 'datetime',
];

public function user()
{
return $this->belongsTo(User::class);
}

public function scopeActive($query)
{
return $query->where('is_active', true);
}

public function scopeForPlatform($query, string $platform)
{
return $query->where('platform', $platform);
}

public function markAsUsed(): void
{
$this->update(['last_used_at' => now()]);
}

public function deactivate(): void
{
$this->update(['is_active' => false]);
}
}

设备令牌服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php

namespace App\Services\Notifications;

use App\Models\DeviceToken;
use App\Models\User;

class DeviceTokenService
{
public function register(User $user, string $token, string $platform, array $deviceInfo = []): DeviceToken
{
$existingToken = DeviceToken::where('token', $token)->first();

if ($existingToken) {
if ($existingToken->user_id !== $user->id) {
$existingToken->update([
'user_id' => $user->id,
'is_active' => true,
]);
}

return $existingToken;
}

return DeviceToken::create([
'user_id' => $user->id,
'token' => $token,
'platform' => $platform,
'device_name' => $deviceInfo['name'] ?? null,
'device_id' => $deviceInfo['id'] ?? null,
'is_active' => true,
]);
}

public function unregister(string $token): bool
{
return DeviceToken::where('token', $token)->delete() > 0;
}

public function deactivateUserTokens(User $user, string $platform = null): int
{
$query = DeviceToken::where('user_id', $user->id);

if ($platform) {
$query->where('platform', $platform);
}

return $query->update(['is_active' => false]);
}

public function getUserTokens(User $user, string $platform = null): array
{
$query = DeviceToken::where('user_id', $user->id)->active();

if ($platform) {
$query->where('platform', $platform);
}

return $query->pluck('token')->toArray();
}

public function cleanupInactive(int $daysInactive = 90): int
{
return DeviceToken::where('last_used_at', '<', now()->subDays($daysInactive))
->orWhere('is_active', false)
->delete();
}
}

FCM 推送

FCM 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<?php

namespace App\Services\Notifications;

use Google\Client as GoogleClient;
use Illuminate\Support\Facades\Http;

class FCMService
{
protected string $projectId;
protected string $accessToken;

public function __construct()
{
$this->projectId = config('services.fcm.project_id');
$this->accessToken = $this->getAccessToken();
}

public function send(string $token, array $notification, array $data = []): array
{
$message = [
'message' => [
'token' => $token,
'notification' => $notification,
'data' => $this->formatData($data),
'android' => [
'notification' => [
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
'sound' => 'default',
],
],
'apns' => [
'payload' => [
'aps' => [
'sound' => 'default',
'badge' => 1,
],
],
],
],
];

$response = Http::withToken($this->accessToken)
->post("https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send", $message);

return $response->json();
}

public function sendMultiple(array $tokens, array $notification, array $data = []): array
{
$results = [];

foreach (array_chunk($tokens, 500) as $chunk) {
$message = [
'message' => [
'notification' => $notification,
'data' => $this->formatData($data),
],
];

foreach ($chunk as $token) {
$message['message']['token'] = $token;
$results[] = $this->send($token, $notification, $data);
}
}

return $results;
}

public function sendToTopic(string $topic, array $notification, array $data = []): array
{
$message = [
'message' => [
'topic' => $topic,
'notification' => $notification,
'data' => $this->formatData($data),
],
];

$response = Http::withToken($this->accessToken)
->post("https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send", $message);

return $response->json();
}

public function subscribeToTopic(string $topic, array $tokens): array
{
$response = Http::withToken($this->accessToken)
->post("https://iid.googleapis.com/iid/v1:batchAdd", [
'to' => "/topics/{$topic}",
'registration_tokens' => $tokens,
]);

return $response->json();
}

protected function getAccessToken(): string
{
$client = new GoogleClient();
$client->setAuthConfig(config('services.fcm.credentials'));
$client->addScope('https://www.googleapis.com/auth/firebase.messaging');

return $client->fetchAccessTokenWithAssertion()['access_token'];
}

protected function formatData(array $data): array
{
return array_map('strval', $data);
}
}

APNS 推送

APNS 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php

namespace App\Services\Notifications;

class APNSService
{
protected string $certificate;
protected string $passphrase;
protected bool $sandbox;

public function __construct()
{
$this->certificate = config('services.apns.certificate');
$this->passphrase = config('services.apns.passphrase', '');
$this->sandbox = config('services.apns.environment') === 'sandbox';
}

public function send(string $token, array $payload, array $options = []): bool
{
$url = $this->getUrl($token);

$body = [
'aps' => [
'alert' => [
'title' => $payload['title'],
'body' => $payload['body'],
],
'sound' => $options['sound'] ?? 'default',
'badge' => $options['badge'] ?? 1,
],
];

if (!empty($payload['data'])) {
$body['data'] = $payload['data'];
}

$json = json_encode($body);

$headers = [
'Content-Type: application/json',
'apns-topic: ' . config('services.apns.bundle_id'),
'apns-priority: ' . ($options['priority'] ?? 10),
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSLCERT => $this->certificate,
CURLOPT_SSLCERTPASSWD => $this->passphrase,
]);

$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

return $httpCode === 200;
}

public function sendMultiple(array $tokens, array $payload, array $options = []): array
{
$results = [];

foreach ($tokens as $token) {
$results[$token] = $this->send($token, $payload, $options);
}

return $results;
}

protected function getUrl(string $token): string
{
$host = $this->sandbox
? 'api.development.push.apple.com'
: 'api.push.apple.com';

return "https://{$host}/3/device/{$token}";
}
}

Web Push

Web Push 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php

namespace App\Services\Notifications;

use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

class WebPushService
{
protected WebPush $webPush;

public function __construct()
{
$this->webPush = new WebPush([
'VAPID' => [
'subject' => config('services.webpush.subject'),
'publicKey' => config('services.webpush.public_key'),
'privateKey' => config('services.webpush.private_key'),
],
]);
}

public function send(string $endpoint, string $publicKey, string $authToken, array $payload): bool
{
$subscription = Subscription::create([
'endpoint' => $endpoint,
'publicKey' => $publicKey,
'authToken' => $authToken,
]);

$result = $this->webPush->sendOneNotification(
$subscription,
json_encode($payload)
);

return $result->isSuccess();
}

public function sendMultiple(array $subscriptions, array $payload): array
{
$results = [];

foreach ($subscriptions as $subscription) {
$sub = Subscription::create($subscription);

$this->webPush->queueNotification($sub, json_encode($payload));
}

foreach ($this->webPush->flush() as $report) {
$results[] = [
'endpoint' => $report->getEndpoint(),
'success' => $report->isSuccess(),
'expired' => $report->isSubscriptionExpired(),
];
}

return $results;
}

public function getPublicKey(): string
{
return config('services.webpush.public_key');
}
}

通知类

推送通知类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php

namespace App\Notifications;

use App\Services\Notifications\FCMService;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;

class PushNotification extends Notification
{
use Queueable;

public function __construct(
protected string $title,
protected string $body,
protected array $data = []
) {}

public function via($notifiable): array
{
return ['fcm'];
}

public function toFcm($notifiable): array
{
return [
'notification' => [
'title' => $this->title,
'body' => $this->body,
],
'data' => $this->data,
];
}

public function toArray($notifiable): array
{
return [
'title' => $this->title,
'body' => $this->body,
'data' => $this->data,
];
}
}

class OrderShippedNotification extends Notification
{
use Queueable;

public function __construct(
protected Order $order
) {}

public function via($notifiable): array
{
return ['database', 'fcm'];
}

public function toFcm($notifiable): array
{
return [
'notification' => [
'title' => 'Order Shipped!',
'body' => "Your order #{$this->order->order_number} has been shipped.",
],
'data' => [
'type' => 'order_shipped',
'order_id' => (string) $this->order->id,
'order_number' => $this->order->order_number,
'tracking_url' => $this->order->tracking_url ?? '',
],
];
}

public function toArray($notifiable): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'tracking_url' => $this->order->tracking_url,
];
}
}

通知服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

namespace App\Services\Notifications;

use App\Models\User;
use App\Notifications\PushNotification;
use Illuminate\Support\Facades\Notification;

class NotificationService
{
protected FCMService $fcm;
protected DeviceTokenService $deviceTokens;

public function __construct(FCMService $fcm, DeviceTokenService $deviceTokens)
{
$this->fcm = $fcm;
$this->deviceTokens = $deviceTokens;
}

public function sendToUser(User $user, string $title, string $body, array $data = []): array
{
$tokens = $this->deviceTokens->getUserTokens($user);

if (empty($tokens)) {
return ['sent' => 0, 'failed' => 0];
}

$user->notify(new PushNotification($title, $body, $data));

return $this->fcm->sendMultiple($tokens, [
'title' => $title,
'body' => $body,
], $data);
}

public function sendToUsers(array $userIds, string $title, string $body, array $data = []): array
{
$users = User::whereIn('id', $userIds)->get();

Notification::send($users, new PushNotification($title, $body, $data));

return ['sent' => count($users)];
}

public function sendToTopic(string $topic, string $title, string $body, array $data = []): array
{
return $this->fcm->sendToTopic($topic, [
'title' => $title,
'body' => $body,
], $data);
}

public function broadcast(string $title, string $body, array $data = []): array
{
return $this->sendToTopic('all', $title, $body, $data);
}
}

推送通知控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\Notifications\DeviceTokenService;
use App\Services\Notifications\NotificationService;
use Illuminate\Http\Request;

class NotificationController extends Controller
{
public function __construct(
protected DeviceTokenService $deviceTokens,
protected NotificationService $notifications
) {}

public function registerToken(Request $request)
{
$request->validate([
'token' => 'required|string',
'platform' => 'required|in:ios,android,web',
'device_name' => 'nullable|string',
'device_id' => 'nullable|string',
]);

$token = $this->deviceTokens->register(
$request->user(),
$request->token,
$request->platform,
$request->only('device_name', 'device_id')
);

return response()->json([
'message' => 'Token registered successfully',
'token' => $token->token,
]);
}

public function unregisterToken(Request $request)
{
$request->validate([
'token' => 'required|string',
]);

$this->deviceTokens->unregister($request->token);

return response()->json([
'message' => 'Token unregistered successfully',
]);
}

public function send(Request $request)
{
$request->validate([
'user_id' => 'required|exists:users,id',
'title' => 'required|string|max:100',
'body' => 'required|string|max:500',
'data' => 'nullable|array',
]);

$user = \App\Models\User::find($request->user_id);

$result = $this->notifications->sendToUser(
$user,
$request->title,
$request->body,
$request->data ?? []
);

return response()->json($result);
}
}

推送通知命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php

namespace App\Console\Commands;

use App\Services\Notifications\DeviceTokenService;
use Illuminate\Console\Command;

class CleanupDeviceTokensCommand extends Command
{
protected $signature = 'notifications:cleanup-tokens {--days=90 : Days of inactivity}';
protected $description = 'Cleanup inactive device tokens';

public function handle(DeviceTokenService $service): int
{
$days = (int) $this->option('days');

$this->info("Cleaning up tokens inactive for {$days} days...");

$count = $service->cleanupInactive($days);

$this->info("Removed {$count} inactive tokens");

return self::SUCCESS;
}
}

总结

Laravel 13 的推送通知系统支持 FCM、APNS 和 Web Push 等多平台推送。通过统一的设备令牌管理和通知服务,可以实现跨平台的实时消息推送,提升用户参与度。