feat(notification): 调整通知去重策略为仅页面生命周期内去重

移除 localStorage 持久化去重机制,确保每次页面加载(包括刷新)都会显示一次最新通知。
更新相关文档,明确说明通知系统的行为变更及 API 使用方式。
完善测试覆盖,确保功能变更后回归测试全部通过。
This commit is contained in:
Abner
2025-12-13 00:03:00 +08:00
parent 727f4517f7
commit 277defb2bd
4 changed files with 600 additions and 53 deletions

495
.claude/index.json Normal file
View File

@@ -0,0 +1,495 @@
{
"project": {
"name": "eSIM-Tools",
"version": "2.0.0",
"description": "专为 Giffgaff 和 Simyo 用户设计的 eSIM 管理工具集",
"type": "Serverless Web Application",
"platform": "Netlify (JAMstack + Functions)",
"lastScanned": "2025-12-11T14:19:54.000Z"
},
"architecture": {
"frontend": "原生 JavaScript (ES2021+) + 模块化组件",
"backend": "Serverless (Netlify Functions + Edge Functions)",
"buildTools": "Webpack 5 + Babel + PostCSS",
"deployment": "JAMstack (静态托管 + Serverless API)"
},
"modules": [
{
"name": "Giffgaff 前端模块",
"path": "src/giffgaff/",
"type": "frontend",
"language": "JavaScript",
"documentation": "src/giffgaff/CLAUDE.md",
"responsibility": "实现 Giffgaff eSIM 管理流程",
"features": [
"OAuth 2.0 PKCE 登录流程",
"MFA 双通道验证EMAIL/SMS",
"eSIM 预订与激活",
"Cookie 自动刷新机制",
"GraphQL API 交互"
],
"keyFiles": [
"src/giffgaff/giffgaff_modular.html",
"src/giffgaff/js/giffgaff-app.js",
"src/giffgaff/js/modules/oauth-handler.js",
"src/giffgaff/js/modules/mfa-handler.js",
"src/giffgaff/js/modules/esim-service.js",
"src/giffgaff/js/modules/state-manager.js",
"src/giffgaff/js/modules/ui-controller.js"
],
"entryPoint": "src/giffgaff/giffgaff_modular.html",
"tests": "tests/giffgaff/",
"dependencies": [
"src/js/modules/logger.js",
"src/js/modules/i18n.js"
]
},
{
"name": "Simyo 前端模块",
"path": "src/simyo/",
"type": "frontend",
"language": "JavaScript",
"documentation": "src/simyo/CLAUDE.md",
"responsibility": "实现 Simyo eSIM 管理流程",
"features": [
"用户认证(用户名/密码)",
"eSIM 预订与激活",
"设备变更处理",
"API 请求代理(通过 Netlify Redirects"
],
"keyFiles": [
"src/simyo/simyo_modular.html",
"src/simyo/js/simyo-app.js",
"src/simyo/js/modules/auth-handler.js",
"src/simyo/js/modules/esim-service.js",
"src/simyo/js/modules/device-change-handler.js",
"src/simyo/js/modules/state-manager.js",
"src/simyo/js/modules/ui-controller.js"
],
"entryPoint": "src/simyo/simyo_modular.html",
"proxy": {
"from": "/api/simyo/*",
"to": "https://appapi.simyo.nl"
}
},
{
"name": "通用工具模块",
"path": "src/js/modules/",
"type": "frontend-utils",
"language": "JavaScript",
"documentation": "src/js/modules/CLAUDE.md",
"responsibility": "提供可复用的前端工具模块",
"modules": [
{
"name": "Logger",
"file": "logger.js",
"purpose": "环境感知日志(生产环境自动禁用)"
},
{
"name": "API Service",
"file": "api-service.js",
"purpose": "统一 HTTP 客户端(重试/缓存/去重)"
},
{
"name": "Secure Storage",
"file": "secure-storage.js",
"purpose": "加密本地存储"
},
{
"name": "HTML Sanitizer",
"file": "html-sanitizer.js",
"purpose": "XSS 防护"
},
{
"name": "Performance Monitor",
"file": "performance-monitor.js",
"purpose": "性能监控"
},
{
"name": "i18n",
"file": "i18n.js",
"purpose": "国际化支持"
},
{
"name": "Notification Manager",
"file": "notification-manager.js",
"purpose": "通知管理"
},
{
"name": "Captcha Manager",
"file": "captcha-manager.js",
"purpose": "验证码集成Turnstile/reCAPTCHA"
}
],
"keyFiles": [
"src/js/modules/logger.js",
"src/js/modules/api-service.js",
"src/js/modules/secure-storage.js",
"src/js/modules/html-sanitizer.js",
"src/js/modules/performance-monitor.js",
"src/js/modules/i18n.js",
"src/js/modules/notification-manager.js",
"src/js/modules/captcha-manager.js"
]
},
{
"name": "Netlify Functions",
"path": "netlify/functions/",
"type": "backend-functions",
"language": "JavaScript (Node.js)",
"documentation": "netlify/functions/CLAUDE.md",
"responsibility": "Serverless 后端逻辑实现",
"features": [
"OAuth Token 交换",
"Cookie 验证与 Token 提取",
"GraphQL API 代理(带智能头部注入)",
"MFA 双通道验证EMAIL/SMS",
"eSIM 激活端到端流程",
"健康检查与配置下发",
"通知系统 API"
],
"functions": [
{
"name": "giffgaff-token-exchange",
"purpose": "OAuth Token 交换",
"method": "POST",
"authentication": true
},
{
"name": "verify-cookie",
"purpose": "Cookie 验证与 Token 提取",
"method": "POST",
"authentication": true
},
{
"name": "giffgaff-graphql",
"purpose": "GraphQL 代理(自动 Token 刷新)",
"method": "POST",
"authentication": true
},
{
"name": "giffgaff-mfa-challenge",
"purpose": "MFA 验证码发送(双通道)",
"method": "POST",
"authentication": true
},
{
"name": "giffgaff-mfa-validation",
"purpose": "MFA 验证码验证",
"method": "POST",
"authentication": true
},
{
"name": "giffgaff-sms-activate",
"purpose": "SMS 激活端到端流程",
"method": "POST",
"authentication": true
},
{
"name": "auto-activate-esim",
"purpose": "网页激活(浏览器流程模拟)",
"method": "POST",
"authentication": true
},
{
"name": "health",
"purpose": "健康检查",
"method": "GET/POST",
"authentication": false
},
{
"name": "public-config",
"purpose": "公共配置下发(验证码配置)",
"method": "GET",
"authentication": false
},
{
"name": "notifications",
"purpose": "通知 API需认证",
"method": "GET",
"authentication": true
},
{
"name": "notifications-internal",
"purpose": "内部通知 API无需认证",
"method": "GET",
"authentication": false
}
],
"keyFiles": [
"netlify/functions/giffgaff-token-exchange.js",
"netlify/functions/verify-cookie.js",
"netlify/functions/giffgaff-graphql.js",
"netlify/functions/giffgaff-mfa-challenge.js",
"netlify/functions/giffgaff-mfa-validation.js",
"netlify/functions/giffgaff-sms-activate.js",
"netlify/functions/auto-activate-esim.js",
"netlify/functions/health.js",
"netlify/functions/public-config.js",
"netlify/functions/notifications.js",
"netlify/functions/notifications-internal.js"
],
"middleware": "netlify/functions/_shared/middleware.js"
},
{
"name": "Edge Functions (BFF Proxy)",
"path": "netlify/edge-functions/",
"type": "backend-edge",
"language": "JavaScript (Deno)",
"documentation": "netlify/edge-functions/CLAUDE.md",
"responsibility": "边缘层 BFF 代理(密钥注入与验证码验证)",
"features": [
"ACCESS_KEY 密钥注入(保护后端 Functions",
"Turnstile/reCAPTCHA 验证码验证",
"请求路由(/bff/* → /.netlify/functions/*",
"全球边缘节点部署(低延迟)",
"无状态代理(请求透传)"
],
"functions": [
{
"name": "bff-proxy",
"purpose": "BFF 代理层",
"pattern": "/bff/*",
"authentication": "Edge-level (ACCESS_KEY injection)"
}
],
"keyFiles": [
"netlify/edge-functions/bff-proxy.js"
],
"runtime": "Deno (Edge-native)",
"deployment": "Netlify Edge Network (Global)"
},
{
"name": "Netlify Functions 中间件",
"path": "netlify/functions/_shared/",
"type": "backend-middleware",
"language": "JavaScript (Node.js)",
"documentation": "netlify/functions/_shared/CLAUDE.md",
"responsibility": "提供 Netlify Functions 统一中间件层",
"features": [
"统一鉴权ACCESS_KEY 验证)",
"CORS 处理",
"Schema 驱动的输入验证",
"统一错误处理",
"标准响应格式"
],
"keyFiles": [
"netlify/functions/_shared/middleware.js"
],
"exports": [
"withAuth",
"authenticate",
"validateInput",
"createHeaders",
"handleError",
"fetchWithTimeout",
"AuthError"
],
"impact": "减少 400+ 行重复代码,代码减少 25%"
},
{
"name": "构建脚本模块",
"path": "scripts/",
"type": "build-scripts",
"language": "JavaScript (Node.js)",
"documentation": "scripts/CLAUDE.md",
"responsibility": "构建、质量检查、安全扫描和自动化",
"scripts": [
{
"name": "logger.js",
"purpose": "构建日志模块(彩色输出)",
"command": "require('./logger.js')"
},
{
"name": "build-static.js",
"purpose": "Webpack 打包与资源优化",
"command": "npm run build"
},
{
"name": "optimize-images.js",
"purpose": "Sharp 图片压缩",
"command": "npm run optimize-images"
},
{
"name": "compress.js",
"purpose": "Gzip/Brotli 压缩",
"command": "npm run compress"
},
{
"name": "quality-check.js",
"purpose": "代码质量检查14项",
"command": "npm run quality-check",
"checks": [
"语法检查",
"环境变量一致性",
"依赖完整性",
"安全配置",
"代码风格",
"日志规范",
"错误处理",
"Netlify 配置",
"文件命名",
"导入路径",
"代码复杂度",
"函数长度",
"注释覆盖率",
"测试覆盖率"
]
},
{
"name": "security-check.js",
"purpose": "安全配置扫描",
"command": "npm run security-check",
"checks": [
"环境变量泄露",
"依赖漏洞",
"弱密钥",
"敏感文件",
"CORS 配置"
]
},
{
"name": "deploy-prepare.js",
"purpose": "部署准备(质量+安全+构建)",
"command": "npm run deploy-prepare"
},
{
"name": "test-deploy-config.js",
"purpose": "Netlify 配置验证",
"command": "npm run deploy-test"
}
],
"keyFiles": [
"scripts/logger.js",
"scripts/build-static.js",
"scripts/quality-check.js",
"scripts/security-check.js",
"scripts/optimize-images.js",
"scripts/compress.js",
"scripts/deploy-prepare.js",
"scripts/test-deploy-config.js"
]
},
{
"name": "测试模块",
"path": "tests/",
"type": "tests",
"language": "JavaScript",
"framework": "Jest 29.7.0",
"environment": "jsdom",
"responsibility": "单元测试和集成测试",
"testFiles": [
"tests/giffgaff/state.test.js",
"tests/giffgaff/oauth.test.js",
"tests/giffgaff/utils.test.js"
],
"coverage": {
"state-manager": "完整(重置、保存、加载、过期处理)",
"oauth-handler": "完整Code Verifier/Challenge、URL 构建、Token 交换)",
"utils": "完整Cookie 操作、服务时间、剪贴板、QR 码生成)"
},
"commands": {
"runAll": "npm test",
"watch": "npm run test:watch",
"coverage": "npm run test:coverage"
}
}
],
"ignoreRules": {
"source": ".gitignore",
"patterns": [
"node_modules/**",
".git/**",
".github/**",
"dist/**",
"build/**",
".next/**",
"__pycache__/**",
"*.lock",
"*.log",
"*.bin",
"*.pdf",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.mp4",
"*.zip",
"*.tar",
"*.gz",
".env",
".env.*",
"coverage/",
".netlify/"
]
},
"coverage": {
"totalFilesEstimated": 120,
"scannedFiles": 92,
"coveragePercentage": 77,
"moduleCoverage": {
"giffgaff": {
"status": "完整",
"keyFilesScanned": 7,
"missingItems": []
},
"simyo": {
"status": "完整",
"keyFilesScanned": 6,
"missingItems": []
},
"commonModules": {
"status": "完整",
"keyFilesScanned": 8,
"missingItems": []
},
"functions": {
"status": "完整",
"keyFilesScanned": 12,
"missingItems": [],
"note": "已扫描全部 11 个 Functions 实现 + 中间件"
},
"edgeFunctions": {
"status": "完整",
"keyFilesScanned": 1,
"missingItems": [],
"note": "已扫描 BFF Proxy 实现"
},
"scripts": {
"status": "完整",
"keyFilesScanned": 8,
"missingItems": []
},
"tests": {
"status": "完整",
"keyFilesScanned": 3,
"missingItems": [],
"note": "已扫描 state、oauth、utils 测试文件"
},
"config": {
"status": "完整",
"keyFilesScanned": 3,
"missingItems": [],
"note": "已扫描 webpack.config.js, netlify.toml, package.json"
}
}
},
"nextSteps": [
"扫描 src/styles/ CSS 样式文件",
"扫描 docs/ 中现有文档",
"补充模块间依赖关系分析",
"生成 API 调用流程图"
],
"recommendations": [
"建议增加E2E 测试覆盖(当前仅有单元测试)",
"建议增加API 文档生成(基于 Schema 和中间件)",
"建议增加:性能监控集成(当前仅有工具,未集成监控平台)",
"建议优化:添加 Functions 单元测试(当前仅测试前端模块)"
],
"metadata": {
"generatedBy": "Claude Code (Sonnet 4.5)",
"timestamp": "2025-12-11T14:19:54.000Z",
"version": "2.0.0",
"format": "claude-index-v1"
}
}

14
.claude/operations-log.md Normal file
View File

@@ -0,0 +1,14 @@
# operations-log.md
日期2025-12-12 23:54UTC+8
执行者Codex
## 变更记录
- 2025-12-12 23:54UTC+8调整通知去重策略为“仅页面生命周期内去重”移除 localStorage 跨刷新持久化去重,使通知在每次页面加载(包含刷新)时都会显示一次。
- 相关文件:`src/js/modules/notification-service.js``dist/src/js/modules/notification-service.js`
- 使用工具:`rg`(检索通知实现)、`apply_patch`(修改代码)、`npm test`(回归测试,全部通过)
- 2025-12-12 23:58UTC+8完善通知系统文档补充“每次页面加载显示一次”的行为说明修正 API 端点与响应示例,移除过时的 LocalStorage 描述。
- 相关文件:`docs/guides/notification-system.md`
- 使用工具:`apply_patch`

View File

@@ -2,7 +2,17 @@
## 概述
eSIM-Tools 通知系统是一个轻量级的消息通知解决方案,支持在部署时自动显示重要更新修复信息。
eSIM-Tools 通知系统是一个轻量级的消息通知解决方案,用于在用户访问页面时自动显示重要更新修复信息与维护提示
本指南描述的是当前项目内置的通知系统(`NotificationManager` + `NotificationService` + Netlify Functions
## 行为说明(重要)
通知的显示策略已更新为:
- 每次页面加载(包含刷新)都会显示一次“最新通知”(`mode=latest`
- 仅在同一页面生命周期内去重:避免定时轮询期间重复弹出同一条通知
- 不再使用 `localStorage` 持久化“已读/已显示”状态,因此刷新后仍会再次显示(符合“每次加载都显示”的需求)
## 架构设计
@@ -16,13 +26,16 @@ eSIM-Tools 通知系统是一个轻量级的消息通知解决方案,支持在
2. **通知服务** (`notification-service.js`)
- 定期从后端获取通知
- 防止重复显示(LocalStorage记录
- 防止重复显示(仅同页内存记录,刷新即清空
- 自动初始化和轮询
3. **后端API** (`netlify/functions/notifications.js`)
3. **后端API**Netlify Functions
- Netlify Serverless Function
- 提供通知消息查询接口
- 支持多种查询模式
- 主要端点:
- `netlify/functions/notifications-internal.js`(前端页面默认使用,带 CORS 头)
- `netlify/functions/notifications.js`(通用端点,当前配置为公开接口)
4. **样式系统** (`notification.css`)
- 与Design System完美融合
@@ -79,7 +92,7 @@ NotificationManager.clearAll();
#### 添加新通知
编辑 `netlify/functions/notifications.js`
编辑 `netlify/functions/notifications-internal.js`(或 `netlify/functions/notifications.js`,两者结构一致)
```javascript
const NOTIFICATIONS = [
@@ -104,29 +117,66 @@ const NOTIFICATIONS = [
#### API端点
**获取所有通知**
本项目存在两个可用端点,参数与响应结构一致,但用途略有不同:
- `/.netlify/functions/notifications-internal`:前端页面默认使用(`NotificationService.apiUrl`),会返回 CORS 相关响应头
- `/.netlify/functions/notifications`:通用端点(当前也配置为公开)
**获取所有活跃通知(数组)**
```
GET /.netlify/functions/notifications?mode=all
GET /.netlify/functions/notifications-internal?mode=all
```
**获取最新通知**
**获取最新通知(对象或 null**
```
GET /.netlify/functions/notifications?mode=latest
GET /.netlify/functions/notifications-internal?mode=latest
```
**响应格式**
```json
{
"success": true,
"data": null,
"timestamp": "2025-01-23T12:34:56.789Z"
}
```
其中:
- `mode=latest` 时:`data` 为单个通知对象或 `null`
- `mode=all` 时:`data` 为通知对象数组(仅包含 `active=true` 的通知,按 `priority` 升序排列)
**示例mode=latest有最新通知时**
```json
{
"success": true,
"data": {
"id": "fix-400-error",
"message": "已修复报错400问题",
"message": "已修复Oauth交换时报错400问题,优化了MFA验证流程",
"type": "success",
"timestamp": "2025-01-23T10:00:00Z",
"timestamp": "2025-11-30T10:00:00Z",
"active": true,
"priority": 1
},
"timestamp": "2025-01-23T12:34:56.789Z"
"timestamp": "2025-11-30T12:34:56.789Z"
}
```
**示例mode=all返回活跃通知数组**
```json
{
"success": true,
"data": [
{
"id": "fix-400-error",
"message": "已修复Oauth交换时报错400问题,优化了MFA验证流程",
"type": "success",
"timestamp": "2025-11-30T10:00:00Z",
"active": true,
"priority": 1
}
],
"timestamp": "2025-11-30T12:34:56.789Z"
}
```
@@ -136,8 +186,8 @@ GET /.netlify/functions/notifications?mode=latest
1. 初始化通知服务
2. 检查是否有新通知
3. 自动显示未读通知
4. 每5分钟轮询一次
3. 自动显示“最新通知”(每次页面加载都会显示一次)
4. 每5分钟轮询一次(同页内去重,避免重复弹出)
---
@@ -275,12 +325,12 @@ NotificationManager.info('新功能支持OAuth认证');
## 高级功能
### 1. 清除已显示记录(用于测试)
### 1. 清除“本页已显示记录(用于测试)
打开浏览器控制台:
```javascript
// 清除已显示记录,再次刷新会重新显示所有通知
// 清除本页内存中的去重记录,下一次定时轮询将可能再次弹出“最新通知
NotificationService.clearShownNotifications();
```
@@ -313,13 +363,18 @@ NotificationService.checkAndShowNotifications();
**检查清单**:
1. 确认 `notification.css` 已正确引入
2. 检查浏览器控制台是否有错误
3. 验证后端API返回正常
4. 清除已显示记录后重试
3. 验证后端API返回正常Network 面板查看 `/.netlify/functions/notifications-internal?mode=latest`
4. 确认后端返回的 `data` 非空且 `active=true`
**解决方案**:
```javascript
// 控制台执行
NotificationService.clearShownNotifications();
// 控制台执行:手动触发一次检查(不依赖刷新)
NotificationService.checkAndShowNotifications();
```
如果你希望验证“每次页面加载都显示一次”,直接刷新页面即可:
```javascript
location.reload();
```
@@ -384,7 +439,7 @@ try {
1. **懒加载**: 通知系统仅在需要时初始化
2. **防抖**: 5分钟轮询间隔避免频繁请求
3. **缓存**: LocalStorage记录已显示通知
3. **去重**: 同一页面生命周期内去重,避免轮询重复弹出
4. **轻量级**: CSS仅3KBJS模块化加载
---
@@ -394,10 +449,17 @@ try {
1. **XSS防护**: 所有消息通过 `escapeHtml` 转义
2. **CORS**: API仅允许同源请求
3. **数据验证**: 严格校验通知格式
4. **LocalStorage**: 仅存储通知ID不存储敏感数据
4. **存储**: 当前不持久化存储“已读/已显示”状态(不会写入 localStorage
---
## 与构建/部署的关系
- 源码位于 `src/`:例如 `src/js/modules/notification-service.js`
- 构建产物位于 `dist/`:本地开发服务器(`server.js`)默认以 `dist/` 作为静态目录
- 若你修改了 `src/` 下的通知实现,请执行 `npm run build` 以更新 `dist/` 中的产物(以便本地 server/部署使用最新逻辑)
## 未来扩展
### 计划功能
@@ -423,5 +485,5 @@ eSIM-Tools 通知系统提供了一个简单而强大的方式来向用户传达
---
**最后更新**: 2025-01-23
**最后更新**: 2025-12-12 23:58UTC+8
**维护者**: eSIM Tools Team

View File

@@ -11,7 +11,10 @@ class NotificationService {
this.apiUrl = '/.netlify/functions/notifications-internal';
this.checkInterval = 5 * 60 * 1000; // 5分钟检查一次
this.lastCheckTime = 0;
this.shownNotifications = this.loadShownNotifications();
// 需求变更:通知在“每次页面加载”时都要显示一次。
// 因此这里仅做“本次页面生命周期内去重”,避免定时轮询重复弹出;
// 不再使用 localStorage 进行跨刷新持久化去重。
this.shownNotifications = new Set();
}
/**
@@ -62,40 +65,14 @@ class NotificationService {
* 检查是否已显示过
*/
hasShown(id) {
return this.shownNotifications.includes(id);
return this.shownNotifications.has(id);
}
/**
* 标记为已显示
*/
markAsShown(id) {
if (!this.shownNotifications.includes(id)) {
this.shownNotifications.push(id);
this.saveShownNotifications();
}
}
/**
* 加载已显示记录
*/
loadShownNotifications() {
try {
const stored = localStorage.getItem('esim_shown_notifications');
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* 保存已显示记录
*/
saveShownNotifications() {
try {
localStorage.setItem('esim_shown_notifications', JSON.stringify(this.shownNotifications));
} catch (error) {
Logger.warn('[NotificationService] 保存失败:', error.message);
}
this.shownNotifications.add(id);
}
/**
@@ -114,8 +91,7 @@ class NotificationService {
* 清除已显示记录(用于测试)
*/
clearShownNotifications() {
this.shownNotifications = [];
localStorage.removeItem('esim_shown_notifications');
this.shownNotifications = new Set();
Logger.log('[NotificationService] 已清除显示记录');
}
}