From e250a7df2f647f12b243ba8f2857f5c1def06456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=AF=9A?= Date: Fri, 2 Jan 2026 12:25:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=92=8C=E5=8F=91=E9=80=81=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/csharpe.prompt.md | 6 ++ Directory.Packages.props | 1 + Entity/PushSubscriptionEntity.cs | 17 +++ Repository/PushSubscriptionRepository.cs | 16 +++ .../AppSettingModel/NotificationSettings.cs | 8 ++ Service/GlobalUsings.cs | 3 +- Service/MessageRecordService.cs | 9 +- Service/NotificationService.cs | 93 ++++++++++++++++ Service/Service.csproj | 1 + Web/public/service-worker.js | 35 +++++- Web/src/App.vue | 47 +++++++- Web/src/api/notification.js | 24 +++++ Web/src/views/SettingView.vue | 100 +++++++++++++++++- WebApi/Controllers/AuthController.cs | 1 - WebApi/Controllers/NotificationController.cs | 27 +++++ WebApi/GlobalUsings.cs | 2 - WebApi/appsettings.json | 5 + 17 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 .github/csharpe.prompt.md create mode 100644 Entity/PushSubscriptionEntity.cs create mode 100644 Repository/PushSubscriptionRepository.cs create mode 100644 Service/AppSettingModel/NotificationSettings.cs create mode 100644 Service/NotificationService.cs create mode 100644 Web/src/api/notification.js create mode 100644 WebApi/Controllers/NotificationController.cs diff --git a/.github/csharpe.prompt.md b/.github/csharpe.prompt.md new file mode 100644 index 0000000..02c1157 --- /dev/null +++ b/.github/csharpe.prompt.md @@ -0,0 +1,6 @@ +# C# Developer Prompt +- 优先使用新C#语法 +- 优先使用中文注释 +- 优先复用已有方法 +- 不要深嵌套代码 +- 保持代码简洁易读 diff --git a/Directory.Packages.props b/Directory.Packages.props index 5a1cee4..dad241b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/Entity/PushSubscriptionEntity.cs b/Entity/PushSubscriptionEntity.cs new file mode 100644 index 0000000..397c99d --- /dev/null +++ b/Entity/PushSubscriptionEntity.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Entity; + +public class PushSubscriptionEntity : BaseEntity +{ + [Required] + public string Endpoint { get; set; } = string.Empty; + + public string? P256DH { get; set; } + + public string? Auth { get; set; } + + public string? UserId { get; set; } // Optional: if you have user authentication + + public string? UserAgent { get; set; } +} diff --git a/Repository/PushSubscriptionRepository.cs b/Repository/PushSubscriptionRepository.cs new file mode 100644 index 0000000..8d4f32e --- /dev/null +++ b/Repository/PushSubscriptionRepository.cs @@ -0,0 +1,16 @@ +namespace Repository; + +public interface IPushSubscriptionRepository : IBaseRepository +{ + Task GetByEndpointAsync(string endpoint); +} + +public class PushSubscriptionRepository(IFreeSql freeSql) : BaseRepository(freeSql), IPushSubscriptionRepository +{ + public async Task GetByEndpointAsync(string endpoint) + { + return await FreeSql.Select() + .Where(x => x.Endpoint == endpoint) + .FirstAsync(); + } +} diff --git a/Service/AppSettingModel/NotificationSettings.cs b/Service/AppSettingModel/NotificationSettings.cs new file mode 100644 index 0000000..50dfbbb --- /dev/null +++ b/Service/AppSettingModel/NotificationSettings.cs @@ -0,0 +1,8 @@ +namespace Service.AppSettingModel; + +public class NotificationSettings +{ + public string Subject { get; set; } = string.Empty; + public string PublicKey { get; set; } = string.Empty; + public string PrivateKey { get; set; } = string.Empty; +} diff --git a/Service/GlobalUsings.cs b/Service/GlobalUsings.cs index 38b7571..05d55e6 100644 --- a/Service/GlobalUsings.cs +++ b/Service/GlobalUsings.cs @@ -11,4 +11,5 @@ global using FreeSql; global using System.Linq; global using Service.AppSettingModel; global using System.Text.Json.Serialization; -global using System.Text.Json.Nodes; \ No newline at end of file +global using System.Text.Json.Nodes; +global using Microsoft.Extensions.Configuration; \ No newline at end of file diff --git a/Service/MessageRecordService.cs b/Service/MessageRecordService.cs index e44b637..69dfd29 100644 --- a/Service/MessageRecordService.cs +++ b/Service/MessageRecordService.cs @@ -12,7 +12,7 @@ public interface IMessageRecordService Task GetUnreadCountAsync(); } -public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessageRecordService +public class MessageRecordService(IMessageRecordRepository messageRepo, INotificationService notificationService) : IMessageRecordService { public async Task<(IEnumerable List, long Total)> GetPagedListAsync(int pageIndex, int pageSize) { @@ -37,7 +37,12 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa Content = content, IsRead = false }; - return await messageRepo.AddAsync(message); + var result = await messageRepo.AddAsync(message); + if (result) + { + await notificationService.SendNotificationAsync($"新增的账单通知: {title}"); + } + return result; } public async Task MarkAsReadAsync(long id) diff --git a/Service/NotificationService.cs b/Service/NotificationService.cs new file mode 100644 index 0000000..6e54c22 --- /dev/null +++ b/Service/NotificationService.cs @@ -0,0 +1,93 @@ +using WebPush; + +namespace Service; + +public interface INotificationService +{ + Task GetVapidPublicKeyAsync(); + Task SubscribeAsync(PushSubscriptionEntity subscription); + Task SendNotificationAsync(string message, string? url = null); +} + +public class NotificationService( + IPushSubscriptionRepository subscriptionRepo, + IConfiguration configuration, + ILogger logger) : INotificationService +{ + private NotificationSettings GetSettings() + { + var settings = configuration.GetSection("NotificationSettings").Get(); + if (settings == null) + { + // Fallback or throw. For now, let's return empty to avoid crashing if not configured, + // but logging error is better. + logger.LogWarning("NotificationSettings not configured"); + return new NotificationSettings(); + } + return settings; + } + + public Task GetVapidPublicKeyAsync() + { + return Task.FromResult(GetSettings().PublicKey); + } + + public async Task SubscribeAsync(PushSubscriptionEntity subscription) + { + var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint); + if (existing != null) + { + existing.P256DH = subscription.P256DH; + existing.Auth = subscription.Auth; + existing.UpdateTime = DateTime.Now; + await subscriptionRepo.UpdateAsync(existing); + } + else + { + await subscriptionRepo.AddAsync(subscription); + } + } + + public async Task SendNotificationAsync(string message, string? url = null) + { + var settings = GetSettings(); + if (string.IsNullOrEmpty(settings.PublicKey) || string.IsNullOrEmpty(settings.PrivateKey)) + { + logger.LogWarning("VAPID keys not configured, skipping notification"); + return; + } + + var vapidDetails = new VapidDetails(settings.Subject, settings.PublicKey, settings.PrivateKey); + var webPushClient = new WebPushClient(); + + var subscriptions = await subscriptionRepo.GetAllAsync(); + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + title = "System Notification", + body = message, + url = url ?? "/", + icon = "/pwa-192x192.png" + }); + + foreach (var sub in subscriptions) + { + try + { + var pushSubscription = new PushSubscription(sub.Endpoint, sub.P256DH, sub.Auth); + await webPushClient.SendNotificationAsync(pushSubscription, payload, vapidDetails); + } + catch (WebPushException ex) + { + if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + await subscriptionRepo.DeleteAsync(sub.Id); + } + logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint); + } + } + } +} diff --git a/Service/Service.csproj b/Service/Service.csproj index a2bf00f..2ddb43e 100644 --- a/Service/Service.csproj +++ b/Service/Service.csproj @@ -21,6 +21,7 @@ + diff --git a/Web/public/service-worker.js b/Web/public/service-worker.js index 9ec0dc7..b9bd96e 100644 --- a/Web/public/service-worker.js +++ b/Web/public/service-worker.js @@ -107,17 +107,29 @@ self.addEventListener('sync', (event) => { // 推送通知 self.addEventListener('push', (event) => { console.log('[Service Worker] 收到推送消息'); + let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' }; + + if (event.data) { + try { + const json = event.data.json(); + data = { ...data, ...json }; + } catch (e) { + data.body = event.data.text(); + } + } + const options = { - body: event.data ? event.data.text() : '您有新的账单消息', - icon: '/icons/icon-192x192.png', + body: data.body, + icon: data.icon, badge: '/icons/icon-72x72.png', vibrate: [200, 100, 200], tag: 'emailbill-notification', - requireInteraction: false + requireInteraction: false, + data: { url: data.url } }; event.waitUntil( - self.registration.showNotification('账单管理', options) + self.registration.showNotification(data.title, options) ); }); @@ -125,8 +137,21 @@ self.addEventListener('push', (event) => { self.addEventListener('notificationclick', (event) => { console.log('[Service Worker] 通知被点击'); event.notification.close(); + const urlToOpen = event.notification.data?.url || '/'; event.waitUntil( - clients.openWindow('/') + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => { + // 如果已经打开了该 URL,则聚焦 + for (let i = 0; i < windowClients.length; i++) { + const client = windowClients[i]; + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + // 否则打开新窗口 + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) ); }); diff --git a/Web/src/App.vue b/Web/src/App.vue index c0334e2..4a57136 100644 --- a/Web/src/App.vue +++ b/Web/src/App.vue @@ -79,12 +79,15 @@ const updateTheme = () => { let mediaQuery onMounted(() => { updateTheme() - messageStore.updateUnreadCount() mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') mediaQuery.addEventListener('change', updateTheme) setActive(route.path) }) +setInterval(() => { + messageStore.updateUnreadCount() +}, 30 * 1000) // 每30秒更新一次未读消息数 + // 监听路由变化调整 watch(() => route.path, (newPath) => { setActive(newPath) @@ -127,6 +130,48 @@ const handleAddTransactionSuccess = () => { window.dispatchEvent(event) } +// 辅助函数:将 Base64 字符串转换为 Uint8Array +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +const subscribeToPush = async () => { + if (!('serviceWorker' in navigator)) return; + + // 1. 获取 VAPID 公钥 + const response = await fetch('/api/notification/vapid-public-key'); + const { publicKey } = await response.json(); + + // 2. 等待 Service Worker 准备就绪 + const registration = await navigator.serviceWorker.ready; + + // 3. 请求订阅 + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) + }); + + // 4. 将订阅信息发送给后端 + // 注意:后端 PushSubscriptionEntity 字段首字母大写,这里需要转换或让后端兼容 + const subJson = subscription.toJSON(); + await fetch('/api/notification/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: subJson.endpoint, + p256dh: subJson.keys.p256dh, + auth: subJson.keys.auth + }) + }); +} +