Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Канал Web Push для центра уведомлений (item 5). Ключи VAPID генерируются один раз
и хранятся в platform_settings (без ручной настройки секретов); публичный отдаётся
фронту. Таблица web_push_subscriptions (Phase42) + /api/web-push/{public-key,
subscribe,unsubscribe}. Отправка через web-push-csharp из фонового джоба минусов
(best-effort, протухшие подписки чистятся). SW: обработчики push/notificationclick
(am-v3→am-v4), клик открывает нужный путь. Кнопка «Вкл. push» в колокольчике.
Работает в т.ч. в iOS Safari для PWA на домашнем экране.
Тесты: integration — публичный ключ стабилен, подписка идемпотентна, 400/unsubscribe
(4/4 группы); unit 299/299.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
4 KiB
C#
92 lines
4 KiB
C#
using System.Text.Json;
|
|
using foodmarket.Application.Notifications;
|
|
using foodmarket.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using WebPush;
|
|
|
|
namespace foodmarket.Infrastructure.Notifications;
|
|
|
|
/// <summary>Реальная отправка Web Push (VAPID) через web-push-csharp. Ключи VAPID
|
|
/// хранятся в PlatformSettings (singleton) — генерируются один раз при первом
|
|
/// обращении. Payload — JSON {title, body, url}, который разбирает service worker.
|
|
///
|
|
/// Best-effort: любая ошибка доставки логируется и не валит вызывающего; подписки,
|
|
/// на которые push-сервис ответил 404/410 (устройство отписалось), удаляются.</summary>
|
|
public sealed class WebPushSender : IWebPushSender
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly ILogger<WebPushSender> _log;
|
|
private static readonly WebPushClient _client = new();
|
|
|
|
public WebPushSender(AppDbContext db, ILogger<WebPushSender> log) { _db = db; _log = log; }
|
|
|
|
// Ключи в БД → всегда можно сгенерировать, поэтому включён всегда.
|
|
public bool IsEnabled => true;
|
|
|
|
public async Task<string> GetPublicKeyAsync(CancellationToken ct = default)
|
|
{
|
|
var (pub, _, _) = await EnsureKeysAsync(ct);
|
|
return pub;
|
|
}
|
|
|
|
private async Task<(string Public, string Private, string Subject)> EnsureKeysAsync(CancellationToken ct)
|
|
{
|
|
var ps = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
|
|
if (ps is null)
|
|
{
|
|
ps = new Domain.Platform.PlatformSettings();
|
|
_db.PlatformSettings.Add(ps);
|
|
}
|
|
if (string.IsNullOrWhiteSpace(ps.WebPushVapidPublicKey) || string.IsNullOrWhiteSpace(ps.WebPushVapidPrivateKey))
|
|
{
|
|
var keys = VapidHelper.GenerateVapidKeys();
|
|
ps.WebPushVapidPublicKey = keys.PublicKey;
|
|
ps.WebPushVapidPrivateKey = keys.PrivateKey;
|
|
await _db.SaveChangesAsync(ct);
|
|
_log.LogInformation("Сгенерирована пара VAPID-ключей для Web Push");
|
|
}
|
|
var subject = string.IsNullOrWhiteSpace(ps.WebPushVapidSubject) ? "mailto:admin@food-market.kz" : ps.WebPushVapidSubject!;
|
|
return (ps.WebPushVapidPublicKey!, ps.WebPushVapidPrivateKey!, subject);
|
|
}
|
|
|
|
public async Task SendToUsersAsync(IReadOnlyCollection<Guid> userIds, string title, string body,
|
|
string? link = null, IReadOnlyDictionary<string, string>? data = null, CancellationToken ct = default)
|
|
{
|
|
if (userIds.Count == 0) return;
|
|
var subs = await _db.WebPushSubscriptions.Where(s => userIds.Contains(s.UserId)).ToListAsync(ct);
|
|
if (subs.Count == 0) return;
|
|
|
|
var (pub, priv, subject) = await EnsureKeysAsync(ct);
|
|
var vapid = new VapidDetails(subject, pub, priv);
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
title, body, url = link ?? "/",
|
|
data = data ?? new Dictionary<string, string>(),
|
|
});
|
|
|
|
var dead = new List<Domain.Notifications.WebPushSubscription>();
|
|
foreach (var s in subs)
|
|
{
|
|
try
|
|
{
|
|
var pushSub = new PushSubscription(s.Endpoint, s.P256dh, s.Auth);
|
|
await _client.SendNotificationAsync(pushSub, payload, vapid, ct);
|
|
}
|
|
catch (WebPushException ex) when ((int)ex.StatusCode is 404 or 410)
|
|
{
|
|
dead.Add(s); // устройство отписалось — удаляем подписку
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogWarning(ex, "Web push не доставлен на подписку {Id}", s.Id);
|
|
}
|
|
}
|
|
if (dead.Count > 0)
|
|
{
|
|
_db.WebPushSubscriptions.RemoveRange(dead);
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
}
|
|
}
|