food-market/src/food-market.infrastructure/Notifications/WebPushSender.cs
nns 560d52e8cd
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
feat(notifications): Web Push (VAPID) — браузерный push о минусах
Канал 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>
2026-10-09 03:49:32 +05:00

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