feat: 添加推送通知功能,支持订阅和发送通知
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 40s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s

This commit is contained in:
2026-01-02 12:25:44 +08:00
parent a055e43451
commit e250a7df2f
17 changed files with 382 additions and 13 deletions

6
.github/csharpe.prompt.md vendored Normal file
View File

@@ -0,0 +1,6 @@
# C# Developer Prompt
- 优先使用新C#语法
- 优先使用中文注释
- 优先复用已有方法
- 不要深嵌套代码
- 保持代码简洁易读

View File

@@ -21,6 +21,7 @@
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
<!-- Database --> <!-- Database -->
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" /> <PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
<PackageVersion Include="WebPush" Version="1.0.12" />
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" /> <PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
<!-- File Processing --> <!-- File Processing -->
<PackageVersion Include="CsvHelper" Version="33.0.1" /> <PackageVersion Include="CsvHelper" Version="33.0.1" />

View File

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

View File

@@ -0,0 +1,16 @@
namespace Repository;
public interface IPushSubscriptionRepository : IBaseRepository<PushSubscriptionEntity>
{
Task<PushSubscriptionEntity?> GetByEndpointAsync(string endpoint);
}
public class PushSubscriptionRepository(IFreeSql freeSql) : BaseRepository<PushSubscriptionEntity>(freeSql), IPushSubscriptionRepository
{
public async Task<PushSubscriptionEntity?> GetByEndpointAsync(string endpoint)
{
return await FreeSql.Select<PushSubscriptionEntity>()
.Where(x => x.Endpoint == endpoint)
.FirstAsync();
}
}

View File

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

View File

@@ -11,4 +11,5 @@ global using FreeSql;
global using System.Linq; global using System.Linq;
global using Service.AppSettingModel; global using Service.AppSettingModel;
global using System.Text.Json.Serialization; global using System.Text.Json.Serialization;
global using System.Text.Json.Nodes; global using System.Text.Json.Nodes;
global using Microsoft.Extensions.Configuration;

View File

@@ -12,7 +12,7 @@ public interface IMessageRecordService
Task<long> GetUnreadCountAsync(); Task<long> GetUnreadCountAsync();
} }
public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessageRecordService public class MessageRecordService(IMessageRecordRepository messageRepo, INotificationService notificationService) : IMessageRecordService
{ {
public async Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize) public async Task<(IEnumerable<MessageRecord> List, long Total)> GetPagedListAsync(int pageIndex, int pageSize)
{ {
@@ -37,7 +37,12 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa
Content = content, Content = content,
IsRead = false IsRead = false
}; };
return await messageRepo.AddAsync(message); var result = await messageRepo.AddAsync(message);
if (result)
{
await notificationService.SendNotificationAsync($"新增的账单通知: {title}");
}
return result;
} }
public async Task<bool> MarkAsReadAsync(long id) public async Task<bool> MarkAsReadAsync(long id)

View File

@@ -0,0 +1,93 @@
using WebPush;
namespace Service;
public interface INotificationService
{
Task<string> GetVapidPublicKeyAsync();
Task SubscribeAsync(PushSubscriptionEntity subscription);
Task SendNotificationAsync(string message, string? url = null);
}
public class NotificationService(
IPushSubscriptionRepository subscriptionRepo,
IConfiguration configuration,
ILogger<NotificationService> logger) : INotificationService
{
private NotificationSettings GetSettings()
{
var settings = configuration.GetSection("NotificationSettings").Get<NotificationSettings>();
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<string> 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);
}
}
}
}

View File

@@ -21,6 +21,7 @@
<PackageReference Include="Quartz.Extensions.Hosting" /> <PackageReference Include="Quartz.Extensions.Hosting" />
<PackageReference Include="JiebaNet.Analyser" /> <PackageReference Include="JiebaNet.Analyser" />
<PackageReference Include="Newtonsoft.Json" /> <PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="WebPush" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -107,17 +107,29 @@ self.addEventListener('sync', (event) => {
// 推送通知 // 推送通知
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
console.log('[Service Worker] 收到推送消息'); 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 = { const options = {
body: event.data ? event.data.text() : '您有新的账单消息', body: data.body,
icon: '/icons/icon-192x192.png', icon: data.icon,
badge: '/icons/icon-72x72.png', badge: '/icons/icon-72x72.png',
vibrate: [200, 100, 200], vibrate: [200, 100, 200],
tag: 'emailbill-notification', tag: 'emailbill-notification',
requireInteraction: false requireInteraction: false,
data: { url: data.url }
}; };
event.waitUntil( event.waitUntil(
self.registration.showNotification('账单管理', options) self.registration.showNotification(data.title, options)
); );
}); });
@@ -125,8 +137,21 @@ self.addEventListener('push', (event) => {
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] 通知被点击'); console.log('[Service Worker] 通知被点击');
event.notification.close(); event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil( 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);
}
})
); );
}); });

View File

@@ -79,12 +79,15 @@ const updateTheme = () => {
let mediaQuery let mediaQuery
onMounted(() => { onMounted(() => {
updateTheme() updateTheme()
messageStore.updateUnreadCount()
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme) mediaQuery.addEventListener('change', updateTheme)
setActive(route.path) setActive(route.path)
}) })
setInterval(() => {
messageStore.updateUnreadCount()
}, 30 * 1000) // 每30秒更新一次未读消息数
// 监听路由变化调整 // 监听路由变化调整
watch(() => route.path, (newPath) => { watch(() => route.path, (newPath) => {
setActive(newPath) setActive(newPath)
@@ -127,6 +130,48 @@ const handleAddTransactionSuccess = () => {
window.dispatchEvent(event) 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
})
});
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,24 @@
import request from './request'
export function getVapidPublicKey() {
return request({
url: '/notification/vapid-public-key',
method: 'get'
})
}
export function subscribe(data) {
return request({
url: '/notification/subscribe',
method: 'post',
data
})
}
export function testNotification(message) {
return request({
url: '/notification/test',
method: 'post',
params: { message }
})
}

View File

@@ -23,6 +23,19 @@
<van-cell title="智能分类" is-link @click="handleSmartClassification" /> <van-cell title="智能分类" is-link @click="handleSmartClassification" />
<!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> --> <!-- <van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" /> -->
</van-cell-group> </van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>通知</p>
</div>
<van-cell-group inset>
<van-cell title="开启消息通知">
<template #right-icon>
<van-switch v-model="notificationEnabled" @change="handleNotificationToggle" size="24" :loading="notificationLoading" />
</template>
</van-cell>
<van-cell title="测试通知" is-link @click="handleTestNotification" v-if="notificationEnabled" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;"> <div class="detail-header" style="padding-bottom: 5px;">
<p>开发者</p> <p>开发者</p>
</div> </div>
@@ -41,16 +54,101 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant' import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant'
import { uploadBillFile } from '@/api/billImport' import { uploadBillFile } from '@/api/billImport'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const fileInputRef = ref(null) const fileInputRef = ref(null)
const currentType = ref('') const currentType = ref('')
const notificationEnabled = ref(false)
const notificationLoading = ref(false)
onMounted(async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
notificationEnabled.value = !!subscription
}
})
function 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 handleNotificationToggle = async (checked) => {
if (!('serviceWorker' in navigator)) {
showToast('您的浏览器不支持推送通知')
notificationEnabled.value = false
return
}
notificationLoading.value = true
try {
const registration = await navigator.serviceWorker.ready
if (checked) {
// 开启通知
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
showToast('请允许通知权限')
notificationEnabled.value = false
return
}
const { data: { publicKey } } = await getVapidPublicKey()
const convertedVapidKey = urlBase64ToUint8Array(publicKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
})
const subJson = subscription.toJSON()
await subscribe({
endpoint: subJson.endpoint,
p256DH: subJson.keys.p256dh,
auth: subJson.keys.auth
})
showSuccessToast('开启成功')
} else {
// 关闭通知
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
}
showSuccessToast('已关闭')
}
} catch (error) {
console.error(error)
showToast('操作失败: ' + (error.message || '未知错误'))
notificationEnabled.value = !checked // 回滚状态
} finally {
notificationLoading.value = false
}
}
const handleTestNotification = async () => {
try {
await testNotification('这是一条测试消息')
showSuccessToast('发送成功,请查看通知栏')
} catch (error) {
console.error(error)
showToast('发送失败')
}
}
/** /**
* 处理导入按钮点击 * 处理导入按钮点击

View File

@@ -1,6 +1,5 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;

View File

@@ -0,0 +1,27 @@
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class NotificationController(INotificationService notificationService) : ControllerBase
{
[HttpGet("vapid-public-key")]
public async Task<IActionResult> GetVapidPublicKey()
{
var key = await notificationService.GetVapidPublicKeyAsync();
return Ok(new { publicKey = key });
}
[HttpPost("subscribe")]
public async Task<IActionResult> Subscribe([FromBody] PushSubscriptionEntity subscription)
{
await notificationService.SubscribeAsync(subscription);
return Ok();
}
[HttpPost("test")]
public async Task<IActionResult> TestNotification([FromQuery] string message)
{
await notificationService.SendNotificationAsync(message);
return Ok();
}
}

View File

@@ -4,6 +4,4 @@ global using Microsoft.AspNetCore.Mvc;
global using WebApi.Controllers.Dto; global using WebApi.Controllers.Dto;
global using Repository; global using Repository;
global using Entity; global using Entity;
global using System.Text.Json;
global using System.Text; global using System.Text;
global using System.Text.Json.Serialization;

View File

@@ -6,6 +6,11 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"NotificationSettings": {
"Subject": "mailto:sunchengsuncheng@gmail.com",
"PublicKey": "BL47vTtPwIb3vUsErdSQ0UtzTXFs2DJkdAefxTVSHGOlqoJOhKsHVgxoUsWt0YILYMe9R-cocG8Vd0zyj_eEfKM",
"PrivateKey": "wBMHddu7F3hK9ZodyYKF0F-Xf1_HeHLIo8ZddCrkINM"
},
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Data Source=database/EmailBill.db" "DefaultConnection": "Data Source=database/EmailBill.db"
}, },